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

Merge pull request #8 from WaveringAna/v0.2

only show register if no users, otherwise show only login

authored by nekomimi.pet and committed by GitHub 01ffe80b d6ecc5fb

Changed files
+116 -185
frontend
src
api
components
src
-120
Cargo.lock
··· 607 ] 608 609 [[package]] 610 - name = "core-foundation" 611 - version = "0.9.4" 612 - source = "registry+https://github.com/rust-lang/crates.io-index" 613 - checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 614 - dependencies = [ 615 - "core-foundation-sys", 616 - "libc", 617 - ] 618 - 619 - [[package]] 620 name = "core-foundation-sys" 621 version = "0.8.7" 622 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 842 version = "0.1.4" 843 source = "registry+https://github.com/rust-lang/crates.io-index" 844 checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" 845 - 846 - [[package]] 847 - name = "foreign-types" 848 - version = "0.3.2" 849 - source = "registry+https://github.com/rust-lang/crates.io-index" 850 - checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 851 - dependencies = [ 852 - "foreign-types-shared", 853 - ] 854 - 855 - [[package]] 856 - name = "foreign-types-shared" 857 - version = "0.1.1" 858 - source = "registry+https://github.com/rust-lang/crates.io-index" 859 - checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 860 861 [[package]] 862 name = "form_urlencoded" ··· 1464 ] 1465 1466 [[package]] 1467 - name = "native-tls" 1468 - version = "0.2.12" 1469 - source = "registry+https://github.com/rust-lang/crates.io-index" 1470 - checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" 1471 - dependencies = [ 1472 - "libc", 1473 - "log", 1474 - "openssl", 1475 - "openssl-probe", 1476 - "openssl-sys", 1477 - "schannel", 1478 - "security-framework", 1479 - "security-framework-sys", 1480 - "tempfile", 1481 - ] 1482 - 1483 - [[package]] 1484 name = "nu-ansi-term" 1485 version = "0.46.0" 1486 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1569 checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 1570 1571 [[package]] 1572 - name = "openssl" 1573 - version = "0.10.68" 1574 - source = "registry+https://github.com/rust-lang/crates.io-index" 1575 - checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" 1576 - dependencies = [ 1577 - "bitflags", 1578 - "cfg-if", 1579 - "foreign-types", 1580 - "libc", 1581 - "once_cell", 1582 - "openssl-macros", 1583 - "openssl-sys", 1584 - ] 1585 - 1586 - [[package]] 1587 - name = "openssl-macros" 1588 - version = "0.1.1" 1589 - source = "registry+https://github.com/rust-lang/crates.io-index" 1590 - checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 1591 - dependencies = [ 1592 - "proc-macro2", 1593 - "quote", 1594 - "syn", 1595 - ] 1596 - 1597 - [[package]] 1598 - name = "openssl-probe" 1599 - version = "0.1.6" 1600 - source = "registry+https://github.com/rust-lang/crates.io-index" 1601 - checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 1602 - 1603 - [[package]] 1604 - name = "openssl-sys" 1605 - version = "0.9.104" 1606 - source = "registry+https://github.com/rust-lang/crates.io-index" 1607 - checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" 1608 - dependencies = [ 1609 - "cc", 1610 - "libc", 1611 - "pkg-config", 1612 - "vcpkg", 1613 - ] 1614 - 1615 - [[package]] 1616 name = "overload" 1617 version = "0.1.1" 1618 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1954 ] 1955 1956 [[package]] 1957 - name = "schannel" 1958 - version = "0.1.27" 1959 - source = "registry+https://github.com/rust-lang/crates.io-index" 1960 - checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" 1961 - dependencies = [ 1962 - "windows-sys 0.59.0", 1963 - ] 1964 - 1965 - [[package]] 1966 name = "scopeguard" 1967 version = "1.2.0" 1968 source = "registry+https://github.com/rust-lang/crates.io-index" 1969 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1970 - 1971 - [[package]] 1972 - name = "security-framework" 1973 - version = "2.11.1" 1974 - source = "registry+https://github.com/rust-lang/crates.io-index" 1975 - checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 1976 - dependencies = [ 1977 - "bitflags", 1978 - "core-foundation", 1979 - "core-foundation-sys", 1980 - "libc", 1981 - "security-framework-sys", 1982 - ] 1983 - 1984 - [[package]] 1985 - name = "security-framework-sys" 1986 - version = "2.14.0" 1987 - source = "registry+https://github.com/rust-lang/crates.io-index" 1988 - checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" 1989 - dependencies = [ 1990 - "core-foundation-sys", 1991 - "libc", 1992 - ] 1993 1994 [[package]] 1995 name = "semver" ··· 2220 "indexmap", 2221 "log", 2222 "memchr", 2223 - "native-tls", 2224 "once_cell", 2225 "percent-encoding", 2226 "serde", ··· 2741 checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" 2742 dependencies = [ 2743 "getrandom", 2744 - "serde", 2745 ] 2746 2747 [[package]]
··· 607 ] 608 609 [[package]] 610 name = "core-foundation-sys" 611 version = "0.8.7" 612 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 832 version = "0.1.4" 833 source = "registry+https://github.com/rust-lang/crates.io-index" 834 checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" 835 836 [[package]] 837 name = "form_urlencoded" ··· 1439 ] 1440 1441 [[package]] 1442 name = "nu-ansi-term" 1443 version = "0.46.0" 1444 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" ··· 1868 ] 1869 1870 [[package]] 1871 name = "scopeguard" 1872 version = "1.2.0" 1873 source = "registry+https://github.com/rust-lang/crates.io-index" 1874 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1875 1876 [[package]] 1877 name = "semver" ··· 2102 "indexmap", 2103 "log", 2104 "memchr", 2105 "once_cell", 2106 "percent-encoding", 2107 "serde", ··· 2622 checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" 2623 dependencies = [ 2624 "getrandom", 2625 ] 2626 2627 [[package]]
+3 -3
Cargo.toml
··· 13 actix-web = "4.4" 14 actix-files = "0.6" 15 actix-cors = "0.6" 16 - tokio = { version = "1.36", features = ["full"] } 17 - sqlx = { version = "0.8", features = ["runtime-tokio-native-tls", "postgres", "sqlite", "chrono"] } 18 serde = { version = "1.0", features = ["derive"] } 19 serde_json = "1.0" 20 anyhow = "1.0" 21 thiserror = "1.0" 22 tracing = "0.1" 23 tracing-subscriber = "0.3" 24 - uuid = { version = "1.7", features = ["v4", "serde"] } 25 base62 = "2.0" 26 clap = { version = "4.5", features = ["derive"] } 27 dotenv = "0.15"
··· 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" 20 anyhow = "1.0" 21 thiserror = "1.0" 22 tracing = "0.1" 23 tracing-subscriber = "0.3" 24 + uuid = { version = "1.7", features = ["v4"] } # Remove serde if not using UUID serialization 25 base62 = "2.0" 26 clap = { version = "4.5", features = ["derive"] } 27 dotenv = "0.15"
+5
frontend/src/api/client.ts
··· 72 return response.data; 73 }; 74 75 export { api };
··· 72 return response.data; 73 }; 74 75 + export const checkFirstUser = async () => { 76 + const response = await api.get<{ isFirstUser: boolean }>('/auth/check-first-user'); 77 + return response.data.isFirstUser; 78 + }; 79 + 80 export { api };
+82 -62
frontend/src/components/AuthForms.tsx
··· 1 - import { useState } from 'react' 2 import { useForm } from 'react-hook-form' 3 import { z } from 'zod' 4 import { zodResolver } from '@hookform/resolvers/zod' ··· 6 import { Button } from '@/components/ui/button' 7 import { Input } from '@/components/ui/input' 8 import { Card } from '@/components/ui/card' 9 - import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' 10 import { 11 Form, 12 FormControl, ··· 16 FormMessage, 17 } from '@/components/ui/form' 18 import { useToast } from '@/hooks/use-toast' 19 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> 27 28 export function AuthForms() { 29 - const [activeTab, setActiveTab] = useState<'login' | 'register'>('login') 30 const { login, register } = useAuth() 31 const { toast } = useToast() 32 ··· 39 }, 40 }) 41 42 const onSubmit = async (values: FormValues) => { 43 try { 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) { ··· 56 } 57 } 58 59 return ( 60 <Card className="w-full max-w-md mx-auto p-6"> 61 - <Tabs value={activeTab} onValueChange={(value: string) => setActiveTab(value as 'login' | 'register')}> 62 - <TabsList className="grid w-full grid-cols-2"> 63 - <TabsTrigger value="login">Login</TabsTrigger> 64 - <TabsTrigger value="register">Register</TabsTrigger> 65 - </TabsList> 66 67 - <TabsContent value={activeTab}> 68 - <Form {...form}> 69 - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> 70 - <FormField 71 - control={form.control} 72 - name="email" 73 - render={({ field }) => ( 74 - <FormItem> 75 - <FormLabel>Email</FormLabel> 76 - <FormControl> 77 - <Input type="email" {...field} /> 78 - </FormControl> 79 - <FormMessage /> 80 - </FormItem> 81 - )} 82 - /> 83 84 - <FormField 85 - control={form.control} 86 - name="password" 87 - render={({ field }) => ( 88 - <FormItem> 89 - <FormLabel>Password</FormLabel> 90 - <FormControl> 91 - <Input type="password" {...field} /> 92 - </FormControl> 93 - <FormMessage /> 94 - </FormItem> 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> 117 - </form> 118 - </Form> 119 - </TabsContent> 120 - </Tabs> 121 </Card> 122 ) 123 }
··· 1 + import { useState, useEffect } from 'react' 2 import { useForm } from 'react-hook-form' 3 import { z } from 'zod' 4 import { zodResolver } from '@hookform/resolvers/zod' ··· 6 import { Button } from '@/components/ui/button' 7 import { Input } from '@/components/ui/input' 8 import { Card } from '@/components/ui/card' 9 import { 10 Form, 11 FormControl, ··· 15 FormMessage, 16 } from '@/components/ui/form' 17 import { useToast } from '@/hooks/use-toast' 18 + import { checkFirstUser } from '../api/client' 19 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().optional(), 24 }) 25 26 type FormValues = z.infer<typeof formSchema> 27 28 export function AuthForms() { 29 + const [isFirstUser, setIsFirstUser] = useState<boolean | null>(null) 30 const { login, register } = useAuth() 31 const { toast } = useToast() 32 ··· 39 }, 40 }) 41 42 + useEffect(() => { 43 + const init = async () => { 44 + try { 45 + const isFirst = await checkFirstUser() 46 + setIsFirstUser(isFirst) 47 + } catch (err) { 48 + console.error('Error checking first user:', err) 49 + setIsFirstUser(false) 50 + } 51 + } 52 + 53 + init() 54 + }, []) 55 + 56 const onSubmit = async (values: FormValues) => { 57 try { 58 + if (isFirstUser) { 59 + await register(values.email, values.password, values.adminToken || '') 60 } else { 61 + await login(values.email, values.password) 62 } 63 form.reset() 64 } catch (err: any) { ··· 70 } 71 } 72 73 + if (isFirstUser === null) { 74 + return <div>Loading...</div> 75 + } 76 + 77 return ( 78 <Card className="w-full max-w-md mx-auto p-6"> 79 + <div className="mb-6 text-center"> 80 + <h2 className="text-2xl font-bold"> 81 + {isFirstUser ? 'Create Admin Account' : 'Login'} 82 + </h2> 83 + <p className="text-sm text-muted-foreground mt-1"> 84 + {isFirstUser 85 + ? 'Set up your admin account to get started' 86 + : 'Welcome back! Please login to your account'} 87 + </p> 88 + </div> 89 90 + <Form {...form}> 91 + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> 92 + <FormField 93 + control={form.control} 94 + name="email" 95 + render={({ field }) => ( 96 + <FormItem> 97 + <FormLabel>Email</FormLabel> 98 + <FormControl> 99 + <Input type="email" {...field} /> 100 + </FormControl> 101 + <FormMessage /> 102 + </FormItem> 103 + )} 104 + /> 105 106 + <FormField 107 + control={form.control} 108 + name="password" 109 + render={({ field }) => ( 110 + <FormItem> 111 + <FormLabel>Password</FormLabel> 112 + <FormControl> 113 + <Input type="password" {...field} /> 114 + </FormControl> 115 + <FormMessage /> 116 + </FormItem> 117 + )} 118 + /> 119 120 + {isFirstUser && ( 121 + <FormField 122 + control={form.control} 123 + name="adminToken" 124 + render={({ field }) => ( 125 + <FormItem> 126 + <FormLabel>Admin Setup Token</FormLabel> 127 + <FormControl> 128 + <Input type="text" {...field} /> 129 + </FormControl> 130 + <FormMessage /> 131 + </FormItem> 132 )} 133 + /> 134 + )} 135 136 + <Button type="submit" className="w-full"> 137 + {isFirstUser ? 'Create Account' : 'Sign in'} 138 + </Button> 139 + </form> 140 + </Form> 141 </Card> 142 ) 143 }
+22
src/handlers.rs
··· 16 use jsonwebtoken::{encode, EncodingKey, Header}; 17 use lazy_static::lazy_static; 18 use regex::Regex; 19 use sqlx::{Postgres, Sqlite}; 20 21 lazy_static! { ··· 690 691 Ok(HttpResponse::Ok().json(sources)) 692 }
··· 16 use jsonwebtoken::{encode, EncodingKey, Header}; 17 use lazy_static::lazy_static; 18 use regex::Regex; 19 + use serde_json::json; 20 use sqlx::{Postgres, Sqlite}; 21 22 lazy_static! { ··· 691 692 Ok(HttpResponse::Ok().json(sources)) 693 } 694 + 695 + pub async fn check_first_user(state: web::Data<AppState>) -> Result<impl Responder, AppError> { 696 + let user_count = match &state.db { 697 + DatabasePool::Postgres(pool) => { 698 + sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users") 699 + .fetch_one(pool) 700 + .await? 701 + .0 702 + } 703 + DatabasePool::Sqlite(pool) => { 704 + sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users") 705 + .fetch_one(pool) 706 + .await? 707 + .0 708 + } 709 + }; 710 + 711 + Ok(HttpResponse::Ok().json(json!({ 712 + "isFirstUser": user_count == 0 713 + }))) 714 + }
+4
src/main.rs
··· 72 ) 73 .route("/auth/register", web::post().to(handlers::register)) 74 .route("/auth/login", web::post().to(handlers::login)) 75 .route("/health", web::get().to(handlers::health_check)), 76 ) 77 .service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url)))
··· 72 ) 73 .route("/auth/register", web::post().to(handlers::register)) 74 .route("/auth/login", web::post().to(handlers::login)) 75 + .route( 76 + "/auth/check-first-user", 77 + web::get().to(handlers::check_first_user), 78 + ) 79 .route("/health", web::get().to(handlers::health_check)), 80 ) 81 .service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url)))