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.

+5 -15
.github/workflows/docker-image.yml
···
- name: Install cosign
if: github.event_name != 'pull_request'
-
uses: sigstore/cosign-installer@v3.7.0
with:
-
cosign-release: "v2.4.1"
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
···
${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ github.repository }}
-
- name: Build and push Docker image (amd64)
-
uses: docker/build-push-action@v6
-
with:
-
context: .
-
file: ./Dockerfile
-
platforms: linux/amd64
-
push: ${{ github.event_name != 'pull_request' }}
-
tags: ${{ steps.meta.outputs.tags }}-amd64
-
labels: ${{ steps.meta.outputs.labels }}
-
-
- name: Build and push Docker image (arm64)
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
-
platforms: linux/arm64
push: ${{ github.event_name != 'pull_request' }}
-
tags: ${{ steps.meta.outputs.tags }}-arm64
labels: ${{ steps.meta.outputs.labels }}
···
- name: Install cosign
if: github.event_name != 'pull_request'
+
uses: sigstore/cosign-installer@v3.8.1
with:
+
cosign-release: "v2.4.3"
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
···
${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ github.repository }}
+
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
+
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
+
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-80
.github/workflows/main.yml
···
-
name: Docker
-
-
on:
-
schedule:
-
- cron: "38 9 * * *"
-
push:
-
branches: ["main"]
-
tags: ["v*.*.*"]
-
pull_request:
-
branches: ["main"]
-
release:
-
types: [published]
-
-
env:
-
REGISTRY: ghcr.io
-
IMAGE_NAME: ${{ github.repository }}
-
-
jobs:
-
build:
-
runs-on: macos-latest
-
permissions:
-
contents: read
-
packages: write
-
id-token: write
-
-
steps:
-
- name: Checkout repository
-
uses: actions/checkout@v3
-
-
- name: Install cosign
-
if: github.event_name != 'pull_request'
-
uses: sigstore/cosign-installer@v3.7.0
-
with:
-
cosign-release: "v2.4.1"
-
-
- name: Setup Docker buildx
-
uses: docker/setup-buildx-action@v3
-
-
- name: Log into registry ${{ env.REGISTRY }}
-
if: github.event_name != 'pull_request'
-
uses: docker/login-action@v3
-
with:
-
registry: ${{ env.REGISTRY }}
-
username: ${{ github.actor }}
-
password: ${{ secrets.GITHUB_TOKEN }}
-
-
- name: Log in to Docker Hub
-
if: github.event_name != 'pull_request'
-
uses: docker/login-action@v3
-
with:
-
username: ${{ secrets.DOCKER_USERNAME }}
-
password: ${{ secrets.DOCKER_PASSWORD }}
-
-
- name: Extract metadata (tags, labels) for Docker
-
id: meta
-
uses: docker/metadata-action@v5
-
with:
-
images: |
-
${{ env.IMAGE_NAME }}
-
${{ env.REGISTRY }}/${{ github.repository }}
-
-
- name: Build and push Docker image (amd64)
-
uses: docker/build-push-action@v6
-
with:
-
context: .
-
file: ./Dockerfile
-
platforms: linux/amd64
-
push: ${{ github.event_name != 'pull_request' }}
-
tags: ${{ steps.meta.outputs.tags }}-amd64
-
labels: ${{ steps.meta.outputs.labels }}
-
-
- name: Build and push Docker image (arm64)
-
uses: docker/build-push-action@v6
-
with:
-
context: .
-
file: ./Dockerfile
-
platforms: linux/arm64
-
push: ${{ github.event_name != 'pull_request' }}
-
tags: ${{ steps.meta.outputs.tags }}-arm64
-
labels: ${{ steps.meta.outputs.labels }}
···
+9 -39
Cargo.lock
···
[[package]]
name = "nu-ansi-term"
-
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
dependencies = [
-
"overload",
-
"winapi",
]
[[package]]
···
version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
-
-
[[package]]
-
name = "overload"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "parking"
···
[[package]]
name = "ring"
-
version = "0.17.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
-
"spin",
"untrusted",
"windows-sys 0.52.0",
]
···
[[package]]
name = "tokio"
-
version = "1.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e"
dependencies = [
"backtrace",
"bytes",
···
[[package]]
name = "tracing-subscriber"
-
version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
dependencies = [
"nu-ansi-term",
"sharded-slab",
···
]
[[package]]
-
name = "winapi"
-
version = "0.3.9"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-
dependencies = [
-
"winapi-i686-pc-windows-gnu",
-
"winapi-x86_64-pc-windows-gnu",
-
]
-
-
[[package]]
-
name = "winapi-i686-pc-windows-gnu"
-
version = "0.4.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-
[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
dependencies = [
"windows-sys 0.59.0",
]
-
-
[[package]]
-
name = "winapi-x86_64-pc-windows-gnu"
-
version = "0.4.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
···
[[package]]
name = "nu-ansi-term"
+
version = "0.50.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
dependencies = [
+
"windows-sys 0.52.0",
]
[[package]]
···
version = "1.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]]
name = "parking"
···
[[package]]
name = "ring"
+
version = "0.17.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
···
[[package]]
name = "tokio"
+
version = "1.43.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c"
dependencies = [
"backtrace",
"bytes",
···
[[package]]
name = "tracing-subscriber"
+
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
dependencies = [
"nu-ansi-term",
"sharded-slab",
···
]
[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
···
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "windows-core"
+1 -1
Cargo.toml
···
actix-web = "4.4"
actix-files = "0.6"
actix-cors = "0.6"
-
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"
···
actix-web = "4.4"
actix-files = "0.6"
actix-cors = "0.6"
+
tokio = { version = "1.43", 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"
+2 -2
Dockerfile
···
WORKDIR /usr/src/frontend
# Copy frontend files
-
COPY frontend/package*.json ./
RUN bun install
COPY frontend/ ./
···
# Copy static files
COPY --from=backend-builder /usr/src/app/static /app/static
-
# Expose the port (this is just documentation)
EXPOSE 8080
# Set default network configuration
···
WORKDIR /usr/src/frontend
# Copy frontend files
+
COPY frontend/package.json ./
RUN bun install
COPY frontend/ ./
···
# Copy static files
COPY --from=backend-builder /usr/src/app/static /app/static
+
# Expose the port
EXPOSE 8080
# Set default network configuration
+42 -8
README.md
···
# SimpleLink
-
A very performant and light (6mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres.
![MainView](readme_img/mainview.jpg)
![StatsView](readme_img/statview.jpg)
## Build
### From Source
First configure .env.example and save it to .env
-
If DATABASE_URL is set, it will connect to a Postgres DB. If blank, it will use an sqlite db in /data
-
```bash
-
#set api-domain to where you will be deploying the link shortener, eg: link.example.com, default is localhost:8080
git clone https://github.com/waveringana/simplelink && cd simplelink
./build.sh
cargo run
```
-
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.
-
-
Alternatively if you want a binary form
```bash
./build.sh --binary
···
### From Docker
```bash
-
docker build --build-arg -t simplelink .
docker run -p 8080:8080 \
-e JWT_SECRET=change-me-in-production \
-v simplelink_data:/data \
simplelink
```
···
### From Docker Compose
Adjust the included docker-compose.yml to your liking; it includes a postgres config as well.
···
# SimpleLink
+
A very performant and light (2MB in memory) link shortener and tracker. Written in Rust and React and uses Postgres or SQLite.
![MainView](readme_img/mainview.jpg)
![StatsView](readme_img/statview.jpg)
+
## How to Run
+
+
### From Docker
+
+
```bash
+
docker run -p 8080:8080 \
+
-e JWT_SECRET=change-me-in-production \
+
-e SIMPLELINK_USER=admin@example.com \
+
-e SIMPLELINK_PASS=your-secure-password \
+
-v simplelink_data:/data \
+
ghcr.io/waveringana/simplelink:v2.2
+
```
+
+
### Environment Variables
+
+
- `JWT_SECRET`: Required. Used for JWT token generation
+
- `SIMPLELINK_USER`: Optional. If set along with SIMPLELINK_PASS, creates an admin user on first run
+
- `SIMPLELINK_PASS`: Optional. Admin user password
+
- `DATABASE_URL`: Optional. Postgres connection string. If not set, uses SQLite
+
- `INITIAL_LINKS`: Optional. Semicolon-separated list of initial links in format "url,code;url2,code2"
+
- `SERVER_HOST`: Optional. Default: "127.0.0.1"
+
- `SERVER_PORT`: Optional. Default: "8080"
+
+
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.
+
+
### From Docker Compose
+
+
Edit the docker-compose.yml file. It comes included with a PostgreSQL db configuration.
+
## Build
### From Source
First configure .env.example and save it to .env
```bash
git clone https://github.com/waveringana/simplelink && cd simplelink
./build.sh
cargo run
```
+
Alternatively for a binary build:
```bash
./build.sh --binary
···
### From Docker
```bash
+
docker build -t simplelink .
docker run -p 8080:8080 \
-e JWT_SECRET=change-me-in-production \
+
-e SIMPLELINK_USER=admin@example.com \
+
-e SIMPLELINK_PASS=your-secure-password \
-v simplelink_data:/data \
simplelink
```
···
### From Docker Compose
Adjust the included docker-compose.yml to your liking; it includes a postgres config as well.
+
+
## Features
+
+
- Support for both PostgreSQL and SQLite databases
+
- Initial links can be configured via environment variables
+
- Admin user can be created on first run via environment variables
+
- Link click tracking and statistics
+
- Lightweight and performant
+1 -3
docker-compose.yml
···
- shortener-network
app:
-
build:
-
context: .
-
dockerfile: Dockerfile
container_name: shortener-app
ports:
- "8080:8080"
···
- shortener-network
app:
+
image: ghcr.io/waveringana/simplelink:v2.2
container_name: shortener-app
ports:
- "8080:8080"
+6
frontend/src/api/client.ts
···
return response.data;
};
export const deleteLink = async (id: number) => {
await api.delete(`/links/${id}`);
};
···
return response.data;
};
+
export const editLink = async (id: number, data: Partial<CreateLinkRequest>) => {
+
const response = await api.patch<Link>(`/links/${id}`, data);
+
return response.data;
+
};
+
+
export const deleteLink = async (id: number) => {
await api.delete(`/links/${id}`);
};
+139
frontend/src/components/EditModal.tsx
···
···
+
// src/components/EditModal.tsx
+
import { useState } from 'react';
+
import { useForm } from 'react-hook-form';
+
import { zodResolver } from '@hookform/resolvers/zod';
+
import * as z from 'zod';
+
import { Link } from '../types/api';
+
import { editLink } from '../api/client';
+
import { useToast } from '@/hooks/use-toast';
+
import {
+
Dialog,
+
DialogContent,
+
DialogHeader,
+
DialogTitle,
+
DialogFooter,
+
} from '@/components/ui/dialog';
+
import { Button } from '@/components/ui/button';
+
import { Input } from '@/components/ui/input';
+
import {
+
Form,
+
FormControl,
+
FormField,
+
FormItem,
+
FormLabel,
+
FormMessage,
+
} from '@/components/ui/form';
+
+
const formSchema = z.object({
+
url: z
+
.string()
+
.min(1, 'URL is required')
+
.url('Must be a valid URL')
+
.refine((val) => val.startsWith('http://') || val.startsWith('https://'), {
+
message: 'URL must start with http:// or https://',
+
}),
+
custom_code: z
+
.string()
+
.regex(/^[a-zA-Z0-9_-]{1,32}$/, {
+
message:
+
'Custom code must be 1-32 characters and contain only letters, numbers, underscores, and hyphens',
+
})
+
.optional(),
+
});
+
+
interface EditModalProps {
+
isOpen: boolean;
+
onClose: () => void;
+
link: Link;
+
onSuccess: () => void;
+
}
+
+
export function EditModal({ isOpen, onClose, link, onSuccess }: EditModalProps) {
+
const [loading, setLoading] = useState(false);
+
const { toast } = useToast();
+
+
const form = useForm<z.infer<typeof formSchema>>({
+
resolver: zodResolver(formSchema),
+
defaultValues: {
+
url: link.original_url,
+
custom_code: link.short_code,
+
},
+
});
+
+
const onSubmit = async (values: z.infer<typeof formSchema>) => {
+
try {
+
setLoading(true);
+
await editLink(link.id, values);
+
toast({
+
description: 'Link updated successfully',
+
});
+
onSuccess();
+
onClose();
+
} catch (err: unknown) {
+
const error = err as { response?: { data?: { error?: string } } };
+
toast({
+
variant: 'destructive',
+
title: 'Error',
+
description: error.response?.data?.error || 'Failed to update link',
+
});
+
} finally {
+
setLoading(false);
+
}
+
};
+
+
return (
+
<Dialog open={isOpen} onOpenChange={onClose}>
+
<DialogContent>
+
<DialogHeader>
+
<DialogTitle>Edit Link</DialogTitle>
+
</DialogHeader>
+
+
<Form {...form}>
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+
<FormField
+
control={form.control}
+
name="url"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Destination URL</FormLabel>
+
<FormControl>
+
<Input placeholder="https://example.com" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
+
+
<FormField
+
control={form.control}
+
name="custom_code"
+
render={({ field }) => (
+
<FormItem>
+
<FormLabel>Short Code</FormLabel>
+
<FormControl>
+
<Input placeholder="custom-code" {...field} />
+
</FormControl>
+
<FormMessage />
+
</FormItem>
+
)}
+
/>
+
+
<DialogFooter>
+
<Button
+
type="button"
+
variant="outline"
+
onClick={onClose}
+
disabled={loading}
+
>
+
Cancel
+
</Button>
+
<Button type="submit" disabled={loading}>
+
{loading ? 'Saving...' : 'Save Changes'}
+
</Button>
+
</DialogFooter>
+
</form>
+
</Form>
+
</DialogContent>
+
</Dialog>
+
);
+
}
+44 -13
frontend/src/components/LinkList.tsx
···
-
import { useEffect, useState } from 'react'
import { Link } from '../types/api'
import { getAllLinks, deleteLink } from '../api/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
···
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast"
-
import { Copy, Trash2, BarChart2 } from "lucide-react"
import {
Dialog,
DialogContent,
···
} from "@/components/ui/dialog"
import { StatisticsModal } from "./StatisticsModal"
interface LinkListProps {
refresh?: number;
···
isOpen: false,
linkId: null,
});
const { toast } = useToast()
-
const fetchLinks = async () => {
try {
setLoading(true)
const data = await getAllLinks()
setLinks(data)
-
} catch (err) {
toast({
title: "Error",
-
description: "Failed to load links",
variant: "destructive",
})
} finally {
setLoading(false)
}
-
}
useEffect(() => {
fetchLinks()
-
}, [refresh]) // Re-fetch when refresh counter changes
const handleDelete = async () => {
if (!deleteModal.linkId) return
···
toast({
description: "Link deleted successfully",
})
-
} catch (err) {
toast({
title: "Error",
-
description: "Failed to delete link",
variant: "destructive",
})
}
···
const baseUrl = window.location.origin
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
toast({
-
description: "Link copied to clipboard",
})
}
···
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
···
<TableHead className="hidden md:table-cell">Original URL</TableHead>
<TableHead>Clicks</TableHead>
<TableHead className="hidden md:table-cell">Created</TableHead>
-
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
···
<TableCell className="hidden md:table-cell">
{new Date(link.created_at).toLocaleDateString()}
</TableCell>
-
<TableCell>
-
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
···
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
>
···
onClose={() => setStatsModal({ isOpen: false, linkId: null })}
linkId={statsModal.linkId!}
/>
</>
)
}
···
+
import { useCallback, useEffect, useState } from 'react'
import { Link } from '../types/api'
import { getAllLinks, deleteLink } from '../api/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
···
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast"
+
import { Copy, Trash2, BarChart2, Pencil } from "lucide-react"
import {
Dialog,
DialogContent,
···
} from "@/components/ui/dialog"
import { StatisticsModal } from "./StatisticsModal"
+
import { EditModal } from './EditModal'
interface LinkListProps {
refresh?: number;
···
isOpen: false,
linkId: null,
});
+
const [editModal, setEditModal] = useState<{ isOpen: boolean; link: Link | null }>({
+
isOpen: false,
+
link: null,
+
});
const { toast } = useToast()
+
const fetchLinks = useCallback(async () => {
try {
setLoading(true)
const data = await getAllLinks()
setLinks(data)
+
} catch (err: unknown) {
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
toast({
title: "Error",
+
description: `Failed to load links: ${errorMessage}`,
variant: "destructive",
})
} finally {
setLoading(false)
}
+
}, [toast, setLinks, setLoading])
useEffect(() => {
fetchLinks()
+
}, [fetchLinks, refresh]) // Re-fetch when refresh counter changes
const handleDelete = async () => {
if (!deleteModal.linkId) return
···
toast({
description: "Link deleted successfully",
})
+
} catch (err: unknown) {
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
toast({
title: "Error",
+
description: `Failed to delete link: ${errorMessage}`,
variant: "destructive",
})
}
···
const baseUrl = window.location.origin
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
toast({
+
description: (
+
<>
+
Link copied to clipboard
+
<br />
+
You can add ?source=TextHere to the end of the link to track the source of clicks
+
</>
+
),
})
}
···
</CardHeader>
<CardContent>
<div className="rounded-md border">
+
<Table>
<TableHeader>
<TableRow>
···
<TableHead className="hidden md:table-cell">Original URL</TableHead>
<TableHead>Clicks</TableHead>
<TableHead className="hidden md:table-cell">Created</TableHead>
+
<TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
···
<TableCell className="hidden md:table-cell">
{new Date(link.created_at).toLocaleDateString()}
</TableCell>
+
<TableCell className="p-2 pr-4">
+
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
···
<Button
variant="ghost"
size="icon"
+
className="h-8 w-8"
+
onClick={() => setEditModal({ isOpen: true, link })}
+
>
+
<Pencil className="h-4 w-4" />
+
<span className="sr-only">Edit Link</span>
+
</Button>
+
<Button
+
variant="ghost"
+
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
>
···
onClose={() => setStatsModal({ isOpen: false, linkId: null })}
linkId={statsModal.linkId!}
/>
+
{editModal.link && (
+
<EditModal
+
isOpen={editModal.isOpen}
+
onClose={() => setEditModal({ isOpen: false, link: null })}
+
link={editModal.link}
+
onSuccess={fetchLinks}
+
/>
+
)}
</>
)
}
+161 -105
frontend/src/components/StatisticsModal.tsx
···
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
-
LineChart,
-
Line,
-
XAxis,
-
YAxis,
-
CartesianGrid,
-
Tooltip,
-
ResponsiveContainer,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-
import { toast } from "@/hooks/use-toast"
-
import { useState, useEffect } from "react";
-
import { getLinkClickStats, getLinkSourceStats } from '../api/client';
-
import { ClickStats, SourceStats } from '../types/api';
interface StatisticsModalProps {
-
isOpen: boolean;
-
onClose: () => void;
-
linkId: number;
}
export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) {
-
const [clicksOverTime, setClicksOverTime] = useState<ClickStats[]>([]);
-
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
-
const [loading, setLoading] = useState(true);
-
useEffect(() => {
-
if (isOpen && linkId) {
-
const fetchData = async () => {
-
try {
-
setLoading(true);
-
const [clicksData, sourcesData] = await Promise.all([
-
getLinkClickStats(linkId),
-
getLinkSourceStats(linkId),
-
]);
-
setClicksOverTime(clicksData);
-
setSourcesData(sourcesData);
-
} catch (error: any) {
-
console.error("Failed to fetch statistics:", error);
-
toast({
-
variant: "destructive",
-
title: "Error",
-
description: error.response?.data || "Failed to load statistics",
-
});
-
} finally {
-
setLoading(false);
-
}
-
};
-
-
fetchData();
-
}
-
}, [isOpen, linkId]);
-
return (
-
<Dialog open={isOpen} onOpenChange={onClose}>
-
<DialogContent className="max-w-3xl">
-
<DialogHeader>
-
<DialogTitle>Link Statistics</DialogTitle>
-
</DialogHeader>
-
{loading ? (
-
<div className="flex items-center justify-center h-64">Loading...</div>
-
) : (
-
<div className="grid gap-4">
-
<Card>
-
<CardHeader>
-
<CardTitle>Clicks Over Time</CardTitle>
-
</CardHeader>
-
<CardContent>
-
<div className="h-[300px]">
-
<ResponsiveContainer width="100%" height="100%">
-
<LineChart data={clicksOverTime}>
-
<CartesianGrid strokeDasharray="3 3" />
-
<XAxis dataKey="date" />
-
<YAxis />
-
<Tooltip />
-
<Line
-
type="monotone"
-
dataKey="clicks"
-
stroke="#8884d8"
-
strokeWidth={2}
-
/>
-
</LineChart>
-
</ResponsiveContainer>
-
</div>
-
</CardContent>
-
</Card>
-
<Card>
-
<CardHeader>
-
<CardTitle>Top Sources</CardTitle>
-
</CardHeader>
-
<CardContent>
-
<ul className="space-y-2">
-
{sourcesData.map((source, index) => (
-
<li
-
key={source.source}
-
className="flex items-center justify-between py-2 border-b last:border-0"
-
>
-
<span className="text-sm">
-
<span className="font-medium text-muted-foreground mr-2">
-
{index + 1}.
-
</span>
-
{source.source}
-
</span>
-
<span className="text-sm font-medium">
-
{source.count} clicks
-
</span>
-
</li>
-
))}
-
</ul>
-
</CardContent>
-
</Card>
-
</div>
-
)}
-
</DialogContent>
-
</Dialog>
);
}
···
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
+
LineChart,
+
Line,
+
XAxis,
+
YAxis,
+
CartesianGrid,
+
Tooltip,
+
ResponsiveContainer,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+
import { toast } from "@/hooks/use-toast";
+
import { useState, useEffect, useMemo } from "react";
+
import { getLinkClickStats, getLinkSourceStats } from "../api/client";
+
import { ClickStats, SourceStats } from "../types/api";
interface StatisticsModalProps {
+
isOpen: boolean;
+
onClose: () => void;
+
linkId: number;
+
}
+
+
interface EnhancedClickStats extends ClickStats {
+
sources?: { source: string; count: number }[];
}
+
const CustomTooltip = ({
+
active,
+
payload,
+
label,
+
}: {
+
active?: boolean;
+
payload?: { value: number; payload: EnhancedClickStats }[];
+
label?: string;
+
}) => {
+
if (active && payload && payload.length > 0) {
+
const data = payload[0].payload;
+
return (
+
<div className="bg-background text-foreground p-4 rounded-lg shadow-lg border">
+
<p className="font-medium">{label}</p>
+
<p className="text-sm">Clicks: {data.clicks}</p>
+
{data.sources && data.sources.length > 0 && (
+
<div className="mt-2">
+
<p className="font-medium text-sm">Sources:</p>
+
<ul className="text-sm">
+
{data.sources.map((source: { source: string; count: number }) => (
+
<li key={source.source}>
+
{source.source}: {source.count}
+
</li>
+
))}
+
</ul>
+
</div>
+
)}
+
</div>
+
);
+
}
+
return null;
+
};
+
export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) {
+
const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]);
+
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
+
const [loading, setLoading] = useState(true);
+
useEffect(() => {
+
if (isOpen && linkId) {
+
const fetchData = async () => {
+
try {
+
setLoading(true);
+
const [clicksData, sourcesData] = await Promise.all([
+
getLinkClickStats(linkId),
+
getLinkSourceStats(linkId),
+
]);
+
// Enhance clicks data with source information
+
const enhancedClicksData = clicksData.map((clickData) => ({
+
...clickData,
+
sources: sourcesData.filter((source) => source.date === clickData.date),
+
}));
+
setClicksOverTime(enhancedClicksData);
+
setSourcesData(sourcesData);
+
} catch (error: unknown) {
+
console.error("Failed to fetch statistics:", error);
+
toast({
+
variant: "destructive",
+
title: "Error",
+
description: error instanceof Error ? error.message : "Failed to load statistics",
+
});
+
} finally {
+
setLoading(false);
+
}
+
};
+
fetchData();
+
}
+
}, [isOpen, linkId]);
+
+
const aggregatedSources = useMemo(() => {
+
const sourceMap = sourcesData.reduce<Record<string, number>>(
+
(acc, { source, count }) => ({
+
...acc,
+
[source]: (acc[source] || 0) + count
+
}),
+
{}
);
+
+
return Object.entries(sourceMap)
+
.map(([source, count]) => ({ source, count }))
+
.sort((a, b) => b.count - a.count);
+
}, [sourcesData]);
+
+
return (
+
<Dialog open={isOpen} onOpenChange={onClose}>
+
<DialogContent className="max-w-3xl">
+
<DialogHeader>
+
<DialogTitle>Link Statistics</DialogTitle>
+
</DialogHeader>
+
+
{loading ? (
+
<div className="flex items-center justify-center h-64">Loading...</div>
+
) : (
+
<div className="grid gap-4">
+
<Card>
+
<CardHeader>
+
<CardTitle>Clicks Over Time</CardTitle>
+
</CardHeader>
+
<CardContent>
+
<div className="h-[300px]">
+
<ResponsiveContainer width="100%" height="100%">
+
<LineChart data={clicksOverTime}>
+
<CartesianGrid strokeDasharray="3 3" />
+
<XAxis dataKey="date" />
+
<YAxis />
+
<Tooltip content={<CustomTooltip />} />
+
<Line
+
type="monotone"
+
dataKey="clicks"
+
stroke="#8884d8"
+
strokeWidth={2}
+
/>
+
</LineChart>
+
</ResponsiveContainer>
+
</div>
+
</CardContent>
+
</Card>
+
+
<Card>
+
<CardHeader>
+
<CardTitle>Top Sources</CardTitle>
+
</CardHeader>
+
<CardContent>
+
<ul className="space-y-2">
+
{aggregatedSources.map((source, index) => (
+
<li
+
key={source.source}
+
className="flex items-center justify-between py-2 border-b last:border-0"
+
>
+
<span className="text-sm">
+
<span className="font-medium text-muted-foreground mr-2">
+
{index + 1}.
+
</span>
+
{source.source}
+
</span>
+
<span className="text-sm font-medium">{source.count} clicks</span>
+
</li>
+
))}
+
</ul>
+
</CardContent>
+
</Card>
+
</div>
+
)}
+
</DialogContent>
+
</Dialog>
+
);
}
+1
frontend/src/types/api.ts
···
}
export interface SourceStats {
source: string;
count: number;
}
···
}
export interface SourceStats {
+
date: string;
source: string;
count: number;
}
+28 -15
frontend/vite.config.ts
···
import tailwindcss from '@tailwindcss/vite'
import path from "path"
-
export default defineConfig(() => ({
-
plugins: [react(), tailwindcss()],
-
/*server: {
-
proxy: {
-
'/api': {
-
target: process.env.VITE_API_URL || 'http://localhost:8080',
-
changeOrigin: true,
},
-
},
-
},*/
-
resolve: {
-
alias: {
-
"@": path.resolve(__dirname, "./src"),
-
},
-
},
-
}))
···
import tailwindcss from '@tailwindcss/vite'
import path from "path"
+
export default defineConfig(({ command }) => {
+
if (command === 'serve') { //command == 'dev'
+
return {
+
server: {
+
proxy: {
+
'/api': {
+
target: process.env.VITE_API_URL || 'http://localhost:8080',
+
changeOrigin: true,
+
},
+
},
+
},
+
plugins: [react(), tailwindcss()],
+
resolve: {
+
alias: {
+
"@": path.resolve(__dirname, "./src"),
+
},
+
},
+
}
+
} else { //command === 'build'
+
return {
+
plugins: [react(), tailwindcss()],
+
resolve: {
+
alias: {
+
"@": path.resolve(__dirname, "./src"),
+
},
},
+
}
+
}
+
})
+3
migrations/20250219000000_extend_short_code.sql
···
···
+
-- PostgreSQL migration
+
ALTER TABLE links ALTER COLUMN short_code TYPE VARCHAR(32);
+
+8 -7
src/auth.rs
···
use actix_web::{dev::Payload, FromRequest, HttpRequest};
use jsonwebtoken::{decode, DecodingKey, Validation};
use std::future::{ready, Ready};
-
use crate::{error::AppError, models::Claims};
pub struct AuthenticatedUser {
pub user_id: i32,
···
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
-
let auth_header = req.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok());
if let Some(auth_header) = auth_header {
if auth_header.starts_with("Bearer ") {
let token = &auth_header[7..];
-
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
-
match decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
-
&Validation::default()
) {
Ok(token_data) => {
return ready(Ok(AuthenticatedUser {
···
}
}
}
-
ready(Err(AppError::Unauthorized))
}
-
}
···
+
use crate::{error::AppError, models::Claims};
use actix_web::{dev::Payload, FromRequest, HttpRequest};
use jsonwebtoken::{decode, DecodingKey, Validation};
use std::future::{ready, Ready};
pub struct AuthenticatedUser {
pub user_id: i32,
···
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
+
let auth_header = req
+
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok());
if let Some(auth_header) = auth_header {
if auth_header.starts_with("Bearer ") {
let token = &auth_header[7..];
+
let secret =
+
std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
match decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
+
&Validation::default(),
) {
Ok(token_data) => {
return ready(Ok(AuthenticatedUser {
···
}
}
}
ready(Err(AppError::Unauthorized))
}
+
}
+
+145 -10
src/handlers.rs
···
Ok(())
}
-
fn validate_url(url: &String) -> Result<(), AppError> {
if url.is_empty() {
return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
}
···
}))
}
pub async fn delete_link(
state: web::Data<AppState>,
user: AuthenticatedUser,
path: web::Path<i32>,
) -> Result<impl Responder, AppError> {
-
let link_id = path.into_inner();
match &state.db {
DatabasePool::Postgres(pool) => {
···
WHERE link_id = $1
GROUP BY DATE(created_at)
ORDER BY DATE(created_at) ASC
-
LIMIT 30
"#,
)
.bind(link_id)
···
WHERE link_id = ?
GROUP BY DATE(created_at)
ORDER BY DATE(created_at) ASC
-
LIMIT 30
"#,
)
.bind(link_id)
···
sqlx::query_as::<_, SourceStats>(
r#"
SELECT
query_source as source,
COUNT(*)::bigint as count
FROM clicks
WHERE link_id = $1
AND query_source IS NOT NULL
AND query_source != ''
-
GROUP BY query_source
-
ORDER BY COUNT(*) DESC
-
LIMIT 10
"#,
)
.bind(link_id)
···
sqlx::query_as::<_, SourceStats>(
r#"
SELECT
query_source as source,
COUNT(*) as count
FROM clicks
WHERE link_id = ?
AND query_source IS NOT NULL
AND query_source != ''
-
GROUP BY query_source
-
ORDER BY COUNT(*) DESC
-
LIMIT 10
"#,
)
.bind(link_id)
···
Ok(())
}
+
fn validate_url(url: &str) -> Result<(), AppError> {
if url.is_empty() {
return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
}
···
}))
}
+
pub async fn edit_link(
+
state: web::Data<AppState>,
+
user: AuthenticatedUser,
+
path: web::Path<i32>,
+
payload: web::Json<CreateLink>,
+
) -> Result<impl Responder, AppError> {
+
let link_id: i32 = path.into_inner();
+
+
// Validate the new URL if provided
+
validate_url(&payload.url)?;
+
+
// Validate custom code if provided
+
if let Some(ref custom_code) = payload.custom_code {
+
validate_custom_code(custom_code)?;
+
+
// Check if the custom code is already taken by another link
+
let existing_link = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1 AND id != $2")
+
.bind(custom_code)
+
.bind(link_id)
+
.fetch_optional(pool)
+
.await?
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = ?1 AND id != ?2")
+
.bind(custom_code)
+
.bind(link_id)
+
.fetch_optional(pool)
+
.await?
+
}
+
};
+
+
if existing_link.is_some() {
+
return Err(AppError::InvalidInput(
+
"Custom code already taken".to_string(),
+
));
+
}
+
}
+
+
// Update the link
+
let updated_link = match &state.db {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
+
// First verify the link belongs to the user
+
let link =
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = $1 AND user_id = $2")
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(&mut *tx)
+
.await?;
+
+
if link.is_none() {
+
return Err(AppError::NotFound);
+
}
+
+
// Update the link
+
let updated = sqlx::query_as::<_, Link>(
+
r#"
+
UPDATE links
+
SET
+
original_url = $1,
+
short_code = COALESCE($2, short_code)
+
WHERE id = $3 AND user_id = $4
+
RETURNING *
+
"#,
+
)
+
.bind(&payload.url)
+
.bind(&payload.custom_code)
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_one(&mut *tx)
+
.await?;
+
+
// If source is provided, add a click record
+
if let Some(ref source) = payload.source {
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)")
+
.bind(link_id)
+
.bind(source)
+
.execute(&mut *tx)
+
.await?;
+
}
+
+
tx.commit().await?;
+
updated
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
+
// First verify the link belongs to the user
+
let link =
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = ?1 AND user_id = ?2")
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_optional(&mut *tx)
+
.await?;
+
+
if link.is_none() {
+
return Err(AppError::NotFound);
+
}
+
+
// Update the link
+
let updated = sqlx::query_as::<_, Link>(
+
r#"
+
UPDATE links
+
SET
+
original_url = ?1,
+
short_code = COALESCE(?2, short_code)
+
WHERE id = ?3 AND user_id = ?4
+
RETURNING *
+
"#,
+
)
+
.bind(&payload.url)
+
.bind(&payload.custom_code)
+
.bind(link_id)
+
.bind(user.user_id)
+
.fetch_one(&mut *tx)
+
.await?;
+
+
// If source is provided, add a click record
+
if let Some(ref source) = payload.source {
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES (?1, ?2)")
+
.bind(link_id)
+
.bind(source)
+
.execute(&mut *tx)
+
.await?;
+
}
+
+
tx.commit().await?;
+
updated
+
}
+
};
+
+
Ok(HttpResponse::Ok().json(updated_link))
+
}
+
pub async fn delete_link(
state: web::Data<AppState>,
user: AuthenticatedUser,
path: web::Path<i32>,
) -> Result<impl Responder, AppError> {
+
let link_id: i32 = path.into_inner();
match &state.db {
DatabasePool::Postgres(pool) => {
···
WHERE link_id = $1
GROUP BY DATE(created_at)
ORDER BY DATE(created_at) ASC
"#,
)
.bind(link_id)
···
WHERE link_id = ?
GROUP BY DATE(created_at)
ORDER BY DATE(created_at) ASC
"#,
)
.bind(link_id)
···
sqlx::query_as::<_, SourceStats>(
r#"
SELECT
+
DATE(created_at)::text as date,
query_source as source,
COUNT(*)::bigint as count
FROM clicks
WHERE link_id = $1
AND query_source IS NOT NULL
AND query_source != ''
+
GROUP BY DATE(created_at), query_source
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
"#,
)
.bind(link_id)
···
sqlx::query_as::<_, SourceStats>(
r#"
SELECT
+
DATE(created_at) as date,
query_source as source,
COUNT(*) as count
FROM clicks
WHERE link_id = ?
AND query_source IS NOT NULL
AND query_source != ''
+
GROUP BY DATE(created_at), query_source
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
"#,
)
.bind(link_id)
+159 -1
src/main.rs
···
use actix_cors::Cors;
use actix_web::{web, App, HttpResponse, HttpServer};
use anyhow::Result;
use rust_embed::RustEmbed;
use simplelink::check_and_generate_admin_token;
use simplelink::{create_db_pool, run_migrations};
use simplelink::{handlers, AppState};
-
use tracing::info;
#[derive(RustEmbed)]
#[folder = "static/"]
struct Asset;
···
}
}
#[actix_web::main]
async fn main() -> Result<()> {
// Load environment variables from .env file
···
let pool = create_db_pool().await?;
run_migrations(&pool).await?;
let admin_token = check_and_generate_admin_token(&pool).await?;
let state = AppState {
···
"/links/{id}/sources",
web::get().to(handlers::get_link_sources),
)
.route("/auth/register", web::post().to(handlers::register))
.route("/auth/login", web::post().to(handlers::login))
.route(
···
use actix_cors::Cors;
use actix_web::{web, App, HttpResponse, HttpServer};
use anyhow::Result;
+
use clap::Parser;
use rust_embed::RustEmbed;
use simplelink::check_and_generate_admin_token;
+
use simplelink::models::DatabasePool;
use simplelink::{create_db_pool, run_migrations};
use simplelink::{handlers, AppState};
+
use sqlx::{Postgres, Sqlite};
+
use tracing::{error, info};
+
#[derive(Parser, Debug)]
+
#[command(author, version, about, long_about = None)]
#[derive(RustEmbed)]
#[folder = "static/"]
struct Asset;
···
}
}
+
async fn create_initial_links(pool: &DatabasePool) -> Result<()> {
+
if let Ok(links) = std::env::var("INITIAL_LINKS") {
+
for link_entry in links.split(';') {
+
let parts: Vec<&str> = link_entry.split(',').collect();
+
if parts.len() >= 2 {
+
let url = parts[0];
+
let code = parts[1];
+
+
match pool {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query(
+
"INSERT INTO links (original_url, short_code, user_id)
+
VALUES ($1, $2, $3)
+
ON CONFLICT (short_code)
+
DO UPDATE SET short_code = EXCLUDED.short_code
+
WHERE links.original_url = EXCLUDED.original_url",
+
)
+
.bind(url)
+
.bind(code)
+
.bind(1)
+
.execute(pool)
+
.await?;
+
}
+
DatabasePool::Sqlite(pool) => {
+
// First check if the exact combination exists
+
let exists = sqlx::query_scalar::<_, bool>(
+
"SELECT EXISTS(
+
SELECT 1 FROM links
+
WHERE original_url = ?1
+
AND short_code = ?2
+
)",
+
)
+
.bind(url)
+
.bind(code)
+
.fetch_one(pool)
+
.await?;
+
+
// Only insert if the exact combination doesn't exist
+
if !exists {
+
sqlx::query(
+
"INSERT INTO links (original_url, short_code, user_id)
+
VALUES (?1, ?2, ?3)",
+
)
+
.bind(url)
+
.bind(code)
+
.bind(1)
+
.execute(pool)
+
.await?;
+
info!("Created initial link: {} -> {} for user_id: 1", code, url);
+
} else {
+
info!("Skipped existing link: {} -> {} for user_id: 1", code, url);
+
}
+
}
+
}
+
}
+
}
+
}
+
Ok(())
+
}
+
+
async fn create_admin_user(pool: &DatabasePool, email: &str, password: &str) -> Result<()> {
+
use argon2::{
+
password_hash::{rand_core::OsRng, SaltString},
+
Argon2, PasswordHasher,
+
};
+
+
let salt = SaltString::generate(&mut OsRng);
+
let argon2 = Argon2::default();
+
let password_hash = argon2
+
.hash_password(password.as_bytes(), &salt)
+
.map_err(|e| anyhow::anyhow!("Password hashing error: {}", e))?
+
.to_string();
+
+
match pool {
+
DatabasePool::Postgres(pool) => {
+
sqlx::query(
+
"INSERT INTO users (email, password_hash)
+
VALUES ($1, $2)
+
ON CONFLICT (email) DO NOTHING",
+
)
+
.bind(email)
+
.bind(&password_hash)
+
.execute(pool)
+
.await?;
+
}
+
DatabasePool::Sqlite(pool) => {
+
sqlx::query(
+
"INSERT OR IGNORE INTO users (email, password_hash)
+
VALUES (?1, ?2)",
+
)
+
.bind(email)
+
.bind(&password_hash)
+
.execute(pool)
+
.await?;
+
}
+
}
+
info!("Created admin user: {}", email);
+
Ok(())
+
}
+
#[actix_web::main]
async fn main() -> Result<()> {
// Load environment variables from .env file
···
let pool = create_db_pool().await?;
run_migrations(&pool).await?;
+
// First check if admin credentials are provided in environment variables
+
let admin_credentials = match (
+
std::env::var("SIMPLELINK_USER"),
+
std::env::var("SIMPLELINK_PASS"),
+
) {
+
(Ok(user), Ok(pass)) => Some((user, pass)),
+
_ => None,
+
};
+
+
if let Some((email, password)) = admin_credentials {
+
// Now check for existing users
+
let user_count = match &pool {
+
DatabasePool::Postgres(pool) => {
+
let mut tx = pool.begin().await?;
+
let count =
+
sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users")
+
.fetch_one(&mut *tx)
+
.await?
+
.0;
+
tx.commit().await?;
+
count
+
}
+
DatabasePool::Sqlite(pool) => {
+
let mut tx = pool.begin().await?;
+
let count = sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users")
+
.fetch_one(&mut *tx)
+
.await?
+
.0;
+
tx.commit().await?;
+
count
+
}
+
};
+
+
if user_count == 0 {
+
info!("No users found, creating admin user: {}", email);
+
match create_admin_user(&pool, &email, &password).await {
+
Ok(_) => info!("Successfully created admin user"),
+
Err(e) => {
+
error!("Failed to create admin user: {}", e);
+
return Err(anyhow::anyhow!("Failed to create admin user: {}", e));
+
}
+
}
+
}
+
} else {
+
info!(
+
"No admin credentials provided in environment variables, skipping admin user creation"
+
);
+
}
+
+
// Create initial links from environment variables
+
create_initial_links(&pool).await?;
+
let admin_token = check_and_generate_admin_token(&pool).await?;
let state = AppState {
···
"/links/{id}/sources",
web::get().to(handlers::get_link_sources),
)
+
.route("/links/{id}", web::patch().to(handlers::edit_link))
.route("/auth/register", web::post().to(handlers::register))
.route("/auth/login", web::post().to(handlers::login))
.route(
+2 -1
src/models.rs
···
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize
-
+ 24 * 60 * 60; // 24 hours from now
Self { sub: user_id, exp }
}
···
#[derive(sqlx::FromRow, Serialize)]
pub struct SourceStats {
pub source: String,
pub count: i64,
}
···
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize
+
+ 14 * 24 * 60 * 60; // 2 weeks from now
Self { sub: user_id, exp }
}
···
#[derive(sqlx::FromRow, Serialize)]
pub struct SourceStats {
+
pub date: String,
pub source: String,
pub count: i64,
}