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

only show register if no users, otherwise show only login

Changed files
+116 -185
frontend
src
api
components
src
-120
Cargo.lock
···
]
[[package]]
-
name = "core-foundation"
-
version = "0.9.4"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
-
dependencies = [
-
"core-foundation-sys",
-
"libc",
-
]
-
-
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
-
-
[[package]]
-
name = "foreign-types"
-
version = "0.3.2"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
-
dependencies = [
-
"foreign-types-shared",
-
]
-
-
[[package]]
-
name = "foreign-types-shared"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
···
[[package]]
-
name = "native-tls"
-
version = "0.2.12"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
-
dependencies = [
-
"libc",
-
"log",
-
"openssl",
-
"openssl-probe",
-
"openssl-sys",
-
"schannel",
-
"security-framework",
-
"security-framework-sys",
-
"tempfile",
-
]
-
-
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
-
name = "openssl"
-
version = "0.10.68"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5"
-
dependencies = [
-
"bitflags",
-
"cfg-if",
-
"foreign-types",
-
"libc",
-
"once_cell",
-
"openssl-macros",
-
"openssl-sys",
-
]
-
-
[[package]]
-
name = "openssl-macros"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
-
dependencies = [
-
"proc-macro2",
-
"quote",
-
"syn",
-
]
-
-
[[package]]
-
name = "openssl-probe"
-
version = "0.1.6"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
-
-
[[package]]
-
name = "openssl-sys"
-
version = "0.9.104"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741"
-
dependencies = [
-
"cc",
-
"libc",
-
"pkg-config",
-
"vcpkg",
-
]
-
-
[[package]]
name = "overload"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
[[package]]
-
name = "schannel"
-
version = "0.1.27"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
-
dependencies = [
-
"windows-sys 0.59.0",
-
]
-
-
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
-
-
[[package]]
-
name = "security-framework"
-
version = "2.11.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
-
dependencies = [
-
"bitflags",
-
"core-foundation",
-
"core-foundation-sys",
-
"libc",
-
"security-framework-sys",
-
]
-
-
[[package]]
-
name = "security-framework-sys"
-
version = "2.14.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
-
dependencies = [
-
"core-foundation-sys",
-
"libc",
-
]
[[package]]
name = "semver"
···
"indexmap",
"log",
"memchr",
-
"native-tls",
"once_cell",
"percent-encoding",
"serde",
···
checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b"
dependencies = [
"getrandom",
-
"serde",
[[package]]
+3 -3
Cargo.toml
···
actix-web = "4.4"
actix-files = "0.6"
actix-cors = "0.6"
-
tokio = { version = "1.36", features = ["full"] }
-
sqlx = { version = "0.8", features = ["runtime-tokio-native-tls", "postgres", "sqlite", "chrono"] }
+
tokio = { version = "1.36", features = ["rt-multi-thread", "macros"] }
+
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "chrono"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
-
uuid = { version = "1.7", features = ["v4", "serde"] }
+
uuid = { version = "1.7", features = ["v4"] } # Remove serde if not using UUID serialization
base62 = "2.0"
clap = { version = "4.5", features = ["derive"] }
dotenv = "0.15"
+5
frontend/src/api/client.ts
···
return response.data;
};
+
export const checkFirstUser = async () => {
+
const response = await api.get<{ isFirstUser: boolean }>('/auth/check-first-user');
+
return response.data.isFirstUser;
+
};
+
export { api };
+82 -62
frontend/src/components/AuthForms.tsx
···
-
import { useState } from 'react'
+
import { useState, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
···
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Form,
FormControl,
···
FormMessage,
} from '@/components/ui/form'
import { useToast } from '@/hooks/use-toast'
+
import { checkFirstUser } from '../api/client'
const formSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters long'),
-
adminToken: z.string(),
+
adminToken: z.string().optional(),
})
type FormValues = z.infer<typeof formSchema>
export function AuthForms() {
-
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login')
+
const [isFirstUser, setIsFirstUser] = useState<boolean | null>(null)
const { login, register } = useAuth()
const { toast } = useToast()
···
},
})
+
useEffect(() => {
+
const init = async () => {
+
try {
+
const isFirst = await checkFirstUser()
+
setIsFirstUser(isFirst)
+
} catch (err) {
+
console.error('Error checking first user:', err)
+
setIsFirstUser(false)
+
}
+
}
+
+
init()
+
}, [])
+
const onSubmit = async (values: FormValues) => {
try {
-
if (activeTab === 'login') {
-
await login(values.email, values.password)
+
if (isFirstUser) {
+
await register(values.email, values.password, values.adminToken || '')
} else {
-
await register(values.email, values.password, values.adminToken)
+
await login(values.email, values.password)
}
form.reset()
} catch (err: any) {
···
}
}
+
if (isFirstUser === null) {
+
return <div>Loading...</div>
+
}
+
return (
<Card className="w-full max-w-md mx-auto p-6">
-
<Tabs value={activeTab} onValueChange={(value: string) => setActiveTab(value as 'login' | 'register')}>
-
<TabsList className="grid w-full grid-cols-2">
-
<TabsTrigger value="login">Login</TabsTrigger>
-
<TabsTrigger value="register">Register</TabsTrigger>
-
</TabsList>
+
<div className="mb-6 text-center">
+
<h2 className="text-2xl font-bold">
+
{isFirstUser ? 'Create Admin Account' : 'Login'}
+
</h2>
+
<p className="text-sm text-muted-foreground mt-1">
+
{isFirstUser
+
? 'Set up your admin account to get started'
+
: 'Welcome back! Please login to your account'}
+
</p>
+
</div>
-
<TabsContent value={activeTab}>
-
<Form {...form}>
-
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
-
<FormField
-
control={form.control}
-
name="email"
-
render={({ field }) => (
-
<FormItem>
-
<FormLabel>Email</FormLabel>
-
<FormControl>
-
<Input type="email" {...field} />
-
</FormControl>
-
<FormMessage />
-
</FormItem>
-
)}
-
/>
+
<Form {...form}>
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+
<FormField
+
control={form.control}
+
name="email"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Email</FormLabel>
+
<FormControl>
+
<Input type="email" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
-
<FormField
-
control={form.control}
-
name="password"
-
render={({ field }) => (
-
<FormItem>
-
<FormLabel>Password</FormLabel>
-
<FormControl>
-
<Input type="password" {...field} />
-
</FormControl>
-
<FormMessage />
-
</FormItem>
-
)}
-
/>
+
<FormField
+
control={form.control}
+
name="password"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Password</FormLabel>
+
<FormControl>
+
<Input type="password" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
-
{activeTab === 'register' && (
-
<FormField
-
control={form.control}
-
name="adminToken"
-
render={({ field }) => (
-
<FormItem>
-
<FormLabel>Admin Setup Token</FormLabel>
-
<FormControl>
-
<Input type="text" {...field} />
-
</FormControl>
-
<FormMessage />
-
</FormItem>
-
)}
-
/>
+
{isFirstUser && (
+
<FormField
+
control={form.control}
+
name="adminToken"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Admin Setup Token</FormLabel>
+
<FormControl>
+
<Input type="text" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
)}
+
/>
+
)}
-
<Button type="submit" className="w-full">
-
{activeTab === 'login' ? 'Sign in' : 'Create account'}
-
</Button>
-
</form>
-
</Form>
-
</TabsContent>
-
</Tabs>
+
<Button type="submit" className="w-full">
+
{isFirstUser ? 'Create Account' : 'Sign in'}
+
</Button>
+
</form>
+
</Form>
</Card>
)
}
+22
src/handlers.rs
···
use jsonwebtoken::{encode, EncodingKey, Header};
use lazy_static::lazy_static;
use regex::Regex;
+
use serde_json::json;
use sqlx::{Postgres, Sqlite};
lazy_static! {
···
Ok(HttpResponse::Ok().json(sources))
}
+
+
pub async fn check_first_user(state: web::Data<AppState>) -> Result<impl Responder, AppError> {
+
let user_count = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users")
+
.fetch_one(pool)
+
.await?
+
.0
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users")
+
.fetch_one(pool)
+
.await?
+
.0
+
}
+
};
+
+
Ok(HttpResponse::Ok().json(json!({
+
"isFirstUser": user_count == 0
+
})))
+
}
+4
src/main.rs
···
)
.route("/auth/register", web::post().to(handlers::register))
.route("/auth/login", web::post().to(handlers::login))
+
.route(
+
"/auth/check-first-user",
+
web::get().to(handlers::check_first_user),
+
)
.route("/health", web::get().to(handlers::health_check)),
)
.service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url)))