+2
-2
.github/workflows/docker-image.yml
+2
-2
.github/workflows/docker-image.yml
+9
-39
Cargo.lock
+9
-39
Cargo.lock
···
1440
1441
[[package]]
1442
name = "nu-ansi-term"
1443
-
version = "0.46.0"
1444
source = "registry+https://github.com/rust-lang/crates.io-index"
1445
-
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
1446
dependencies = [
1447
-
"overload",
1448
-
"winapi",
1449
]
1450
1451
[[package]]
···
1525
version = "1.20.2"
1526
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"
1533
-
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
1534
1535
[[package]]
1536
name = "parking"
···
1751
1752
[[package]]
1753
name = "ring"
1754
-
version = "0.17.8"
1755
source = "registry+https://github.com/rust-lang/crates.io-index"
1756
-
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
1757
dependencies = [
1758
"cc",
1759
"cfg-if",
1760
"getrandom",
1761
"libc",
1762
-
"spin",
1763
"untrusted",
1764
"windows-sys 0.52.0",
1765
]
···
2432
2433
[[package]]
2434
name = "tokio"
2435
-
version = "1.43.0"
2436
source = "registry+https://github.com/rust-lang/crates.io-index"
2437
-
checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e"
2438
dependencies = [
2439
"backtrace",
2440
"bytes",
···
2529
2530
[[package]]
2531
name = "tracing-subscriber"
2532
-
version = "0.3.19"
2533
source = "registry+https://github.com/rust-lang/crates.io-index"
2534
-
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
2535
dependencies = [
2536
"nu-ansi-term",
2537
"sharded-slab",
···
2739
]
2740
2741
[[package]]
2742
-
name = "winapi"
2743
-
version = "0.3.9"
2744
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2745
-
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
2746
-
dependencies = [
2747
-
"winapi-i686-pc-windows-gnu",
2748
-
"winapi-x86_64-pc-windows-gnu",
2749
-
]
2750
-
2751
-
[[package]]
2752
-
name = "winapi-i686-pc-windows-gnu"
2753
-
version = "0.4.0"
2754
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2755
-
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
2756
-
2757
-
[[package]]
2758
name = "winapi-util"
2759
version = "0.1.9"
2760
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2762
dependencies = [
2763
"windows-sys 0.59.0",
2764
]
2765
-
2766
-
[[package]]
2767
-
name = "winapi-x86_64-pc-windows-gnu"
2768
-
version = "0.4.0"
2769
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2770
-
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
2771
2772
[[package]]
2773
name = "windows-core"
···
1440
1441
[[package]]
1442
name = "nu-ansi-term"
1443
+
version = "0.50.1"
1444
source = "registry+https://github.com/rust-lang/crates.io-index"
1445
+
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
1446
dependencies = [
1447
+
"windows-sys 0.52.0",
1448
]
1449
1450
[[package]]
···
1524
version = "1.20.2"
1525
source = "registry+https://github.com/rust-lang/crates.io-index"
1526
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
1527
1528
[[package]]
1529
name = "parking"
···
1744
1745
[[package]]
1746
name = "ring"
1747
+
version = "0.17.13"
1748
source = "registry+https://github.com/rust-lang/crates.io-index"
1749
+
checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee"
1750
dependencies = [
1751
"cc",
1752
"cfg-if",
1753
"getrandom",
1754
"libc",
1755
"untrusted",
1756
"windows-sys 0.52.0",
1757
]
···
2424
2425
[[package]]
2426
name = "tokio"
2427
+
version = "1.43.1"
2428
source = "registry+https://github.com/rust-lang/crates.io-index"
2429
+
checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c"
2430
dependencies = [
2431
"backtrace",
2432
"bytes",
···
2521
2522
[[package]]
2523
name = "tracing-subscriber"
2524
+
version = "0.3.20"
2525
source = "registry+https://github.com/rust-lang/crates.io-index"
2526
+
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
2527
dependencies = [
2528
"nu-ansi-term",
2529
"sharded-slab",
···
2731
]
2732
2733
[[package]]
2734
name = "winapi-util"
2735
version = "0.1.9"
2736
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2738
dependencies = [
2739
"windows-sys 0.59.0",
2740
]
2741
2742
[[package]]
2743
name = "windows-core"
+1
-1
Cargo.toml
+1
-1
Cargo.toml
···
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"
···
13
actix-web = "4.4"
14
actix-files = "0.6"
15
actix-cors = "0.6"
16
+
tokio = { version = "1.43", 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"
+1
-1
Dockerfile
+1
-1
Dockerfile
+29
-13
README.md
+29
-13
README.md
···
1
# SimpleLink
2
3
-
A very performant and light (6mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres.
4
5

6
···
8
9
## How to Run
10
11
-
### From Docker:
12
13
-
```Bash
14
docker run -p 8080:8080 \
15
-e JWT_SECRET=change-me-in-production \
16
-v simplelink_data:/data \
17
-
simplelink
18
```
19
20
-
Find the admin-setup-token pasted into the terminal output, or in admin-setup-token.txt in the container's root.
21
22
-
This is needed to register with the frontend. (TODO, register admin account with ENV)
23
24
-
### From Docker Compose:
25
26
-
Edit the docker-compose.yml file. It comes included with a postgressql db for use
27
28
## Build
29
···
31
32
First configure .env.example and save it to .env
33
34
-
If DATABASE_URL is set, it will connect to a Postgres DB. If blank, it will use an sqlite db in /data
35
-
36
```bash
37
git clone https://github.com/waveringana/simplelink && cd simplelink
38
./build.sh
39
cargo run
40
```
41
42
-
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.
43
-
44
-
Alternatively if you want a binary form
45
46
```bash
47
./build.sh --binary
···
55
docker build -t simplelink .
56
docker run -p 8080:8080 \
57
-e JWT_SECRET=change-me-in-production \
58
-v simplelink_data:/data \
59
simplelink
60
```
···
62
### From Docker Compose
63
64
Adjust the included docker-compose.yml to your liking; it includes a postgres config as well.
···
1
# SimpleLink
2
3
+
A very performant and light (2MB in memory) link shortener and tracker. Written in Rust and React and uses Postgres or SQLite.
4
5

6
···
8
9
## How to Run
10
11
+
### From Docker
12
13
+
```bash
14
docker run -p 8080:8080 \
15
-e JWT_SECRET=change-me-in-production \
16
+
-e SIMPLELINK_USER=admin@example.com \
17
+
-e SIMPLELINK_PASS=your-secure-password \
18
-v simplelink_data:/data \
19
+
ghcr.io/waveringana/simplelink:v2.2
20
```
21
22
+
### Environment Variables
23
24
+
- `JWT_SECRET`: Required. Used for JWT token generation
25
+
- `SIMPLELINK_USER`: Optional. If set along with SIMPLELINK_PASS, creates an admin user on first run
26
+
- `SIMPLELINK_PASS`: Optional. Admin user password
27
+
- `DATABASE_URL`: Optional. Postgres connection string. If not set, uses SQLite
28
+
- `INITIAL_LINKS`: Optional. Semicolon-separated list of initial links in format "url,code;url2,code2"
29
+
- `SERVER_HOST`: Optional. Default: "127.0.0.1"
30
+
- `SERVER_PORT`: Optional. Default: "8080"
31
32
+
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.
33
+
34
+
### From Docker Compose
35
36
+
Edit the docker-compose.yml file. It comes included with a PostgreSQL db configuration.
37
38
## Build
39
···
41
42
First configure .env.example and save it to .env
43
44
```bash
45
git clone https://github.com/waveringana/simplelink && cd simplelink
46
./build.sh
47
cargo run
48
```
49
50
+
Alternatively for a binary build:
51
52
```bash
53
./build.sh --binary
···
61
docker build -t simplelink .
62
docker run -p 8080:8080 \
63
-e JWT_SECRET=change-me-in-production \
64
+
-e SIMPLELINK_USER=admin@example.com \
65
+
-e SIMPLELINK_PASS=your-secure-password \
66
-v simplelink_data:/data \
67
simplelink
68
```
···
70
### From Docker Compose
71
72
Adjust the included docker-compose.yml to your liking; it includes a postgres config as well.
73
+
74
+
## Features
75
+
76
+
- Support for both PostgreSQL and SQLite databases
77
+
- Initial links can be configured via environment variables
78
+
- Admin user can be created on first run via environment variables
79
+
- Link click tracking and statistics
80
+
- Lightweight and performant
+1
-3
docker-compose.yml
+1
-3
docker-compose.yml
+6
frontend/src/api/client.ts
+6
frontend/src/api/client.ts
···
58
return response.data;
59
};
60
61
+
export const editLink = async (id: number, data: Partial<CreateLinkRequest>) => {
62
+
const response = await api.patch<Link>(`/links/${id}`, data);
63
+
return response.data;
64
+
};
65
+
66
+
67
export const deleteLink = async (id: number) => {
68
await api.delete(`/links/${id}`);
69
};
+139
frontend/src/components/EditModal.tsx
+139
frontend/src/components/EditModal.tsx
···
···
1
+
// src/components/EditModal.tsx
2
+
import { useState } from 'react';
3
+
import { useForm } from 'react-hook-form';
4
+
import { zodResolver } from '@hookform/resolvers/zod';
5
+
import * as z from 'zod';
6
+
import { Link } from '../types/api';
7
+
import { editLink } from '../api/client';
8
+
import { useToast } from '@/hooks/use-toast';
9
+
import {
10
+
Dialog,
11
+
DialogContent,
12
+
DialogHeader,
13
+
DialogTitle,
14
+
DialogFooter,
15
+
} from '@/components/ui/dialog';
16
+
import { Button } from '@/components/ui/button';
17
+
import { Input } from '@/components/ui/input';
18
+
import {
19
+
Form,
20
+
FormControl,
21
+
FormField,
22
+
FormItem,
23
+
FormLabel,
24
+
FormMessage,
25
+
} from '@/components/ui/form';
26
+
27
+
const formSchema = z.object({
28
+
url: z
29
+
.string()
30
+
.min(1, 'URL is required')
31
+
.url('Must be a valid URL')
32
+
.refine((val) => val.startsWith('http://') || val.startsWith('https://'), {
33
+
message: 'URL must start with http:// or https://',
34
+
}),
35
+
custom_code: z
36
+
.string()
37
+
.regex(/^[a-zA-Z0-9_-]{1,32}$/, {
38
+
message:
39
+
'Custom code must be 1-32 characters and contain only letters, numbers, underscores, and hyphens',
40
+
})
41
+
.optional(),
42
+
});
43
+
44
+
interface EditModalProps {
45
+
isOpen: boolean;
46
+
onClose: () => void;
47
+
link: Link;
48
+
onSuccess: () => void;
49
+
}
50
+
51
+
export function EditModal({ isOpen, onClose, link, onSuccess }: EditModalProps) {
52
+
const [loading, setLoading] = useState(false);
53
+
const { toast } = useToast();
54
+
55
+
const form = useForm<z.infer<typeof formSchema>>({
56
+
resolver: zodResolver(formSchema),
57
+
defaultValues: {
58
+
url: link.original_url,
59
+
custom_code: link.short_code,
60
+
},
61
+
});
62
+
63
+
const onSubmit = async (values: z.infer<typeof formSchema>) => {
64
+
try {
65
+
setLoading(true);
66
+
await editLink(link.id, values);
67
+
toast({
68
+
description: 'Link updated successfully',
69
+
});
70
+
onSuccess();
71
+
onClose();
72
+
} catch (err: unknown) {
73
+
const error = err as { response?: { data?: { error?: string } } };
74
+
toast({
75
+
variant: 'destructive',
76
+
title: 'Error',
77
+
description: error.response?.data?.error || 'Failed to update link',
78
+
});
79
+
} finally {
80
+
setLoading(false);
81
+
}
82
+
};
83
+
84
+
return (
85
+
<Dialog open={isOpen} onOpenChange={onClose}>
86
+
<DialogContent>
87
+
<DialogHeader>
88
+
<DialogTitle>Edit Link</DialogTitle>
89
+
</DialogHeader>
90
+
91
+
<Form {...form}>
92
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
93
+
<FormField
94
+
control={form.control}
95
+
name="url"
96
+
render={({ field }) => (
97
+
<FormItem>
98
+
<FormLabel>Destination URL</FormLabel>
99
+
<FormControl>
100
+
<Input placeholder="https://example.com" {...field} />
101
+
</FormControl>
102
+
<FormMessage />
103
+
</FormItem>
104
+
)}
105
+
/>
106
+
107
+
<FormField
108
+
control={form.control}
109
+
name="custom_code"
110
+
render={({ field }) => (
111
+
<FormItem>
112
+
<FormLabel>Short Code</FormLabel>
113
+
<FormControl>
114
+
<Input placeholder="custom-code" {...field} />
115
+
</FormControl>
116
+
<FormMessage />
117
+
</FormItem>
118
+
)}
119
+
/>
120
+
121
+
<DialogFooter>
122
+
<Button
123
+
type="button"
124
+
variant="outline"
125
+
onClick={onClose}
126
+
disabled={loading}
127
+
>
128
+
Cancel
129
+
</Button>
130
+
<Button type="submit" disabled={loading}>
131
+
{loading ? 'Saving...' : 'Save Changes'}
132
+
</Button>
133
+
</DialogFooter>
134
+
</form>
135
+
</Form>
136
+
</DialogContent>
137
+
</Dialog>
138
+
);
139
+
}
+42
-23
frontend/src/components/LinkList.tsx
+42
-23
frontend/src/components/LinkList.tsx
···
1
-
import { lazy, Suspense } from 'react'
2
-
3
-
import { useEffect, useState } from 'react'
4
import { Link } from '../types/api'
5
import { getAllLinks, deleteLink } from '../api/client'
6
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
···
14
} from "@/components/ui/table"
15
import { Button } from "@/components/ui/button"
16
import { useToast } from "@/hooks/use-toast"
17
-
import { Copy, Trash2, BarChart2 } from "lucide-react"
18
import {
19
Dialog,
20
DialogContent,
···
24
DialogFooter,
25
} from "@/components/ui/dialog"
26
27
-
const StatisticsModal = lazy(() => import('./StatisticsModal'))
28
29
interface LinkListProps {
30
refresh?: number;
···
41
isOpen: false,
42
linkId: null,
43
});
44
const { toast } = useToast()
45
46
-
const fetchLinks = async () => {
47
try {
48
setLoading(true)
49
const data = await getAllLinks()
50
setLinks(data)
51
-
} catch (err) {
52
toast({
53
title: "Error",
54
-
description: "Failed to load links",
55
variant: "destructive",
56
})
57
} finally {
58
setLoading(false)
59
}
60
-
}
61
62
useEffect(() => {
63
fetchLinks()
64
-
}, [refresh]) // Re-fetch when refresh counter changes
65
66
const handleDelete = async () => {
67
if (!deleteModal.linkId) return
···
73
toast({
74
description: "Link deleted successfully",
75
})
76
-
} catch (err) {
77
toast({
78
title: "Error",
79
-
description: "Failed to delete link",
80
variant: "destructive",
81
})
82
}
···
129
</CardHeader>
130
<CardContent>
131
<div className="rounded-md border">
132
<Table>
133
<TableHeader>
134
<TableRow>
···
136
<TableHead className="hidden md:table-cell">Original URL</TableHead>
137
<TableHead>Clicks</TableHead>
138
<TableHead className="hidden md:table-cell">Created</TableHead>
139
-
<TableHead>Actions</TableHead>
140
</TableRow>
141
</TableHeader>
142
<TableBody>
···
150
<TableCell className="hidden md:table-cell">
151
{new Date(link.created_at).toLocaleDateString()}
152
</TableCell>
153
-
<TableCell>
154
-
<div className="flex gap-2">
155
<Button
156
variant="ghost"
157
size="icon"
···
173
<Button
174
variant="ghost"
175
size="icon"
176
className="h-8 w-8 text-destructive"
177
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
178
>
···
188
</div>
189
</CardContent>
190
</Card>
191
-
{statsModal.isOpen && (
192
-
<Suspense fallback={<div>Loading...</div>}>
193
-
<StatisticsModal
194
-
isOpen={statsModal.isOpen}
195
-
onClose={() => setStatsModal({ isOpen: false, linkId: null })}
196
-
linkId={statsModal.linkId!}
197
-
/>
198
-
</Suspense>
199
)}
200
</>
201
)
···
1
+
import { useCallback, useEffect, useState } from 'react'
2
import { Link } from '../types/api'
3
import { getAllLinks, deleteLink } from '../api/client'
4
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
···
12
} from "@/components/ui/table"
13
import { Button } from "@/components/ui/button"
14
import { useToast } from "@/hooks/use-toast"
15
+
import { Copy, Trash2, BarChart2, Pencil } from "lucide-react"
16
import {
17
Dialog,
18
DialogContent,
···
22
DialogFooter,
23
} from "@/components/ui/dialog"
24
25
+
import { StatisticsModal } from "./StatisticsModal"
26
+
import { EditModal } from './EditModal'
27
28
interface LinkListProps {
29
refresh?: number;
···
40
isOpen: false,
41
linkId: null,
42
});
43
+
const [editModal, setEditModal] = useState<{ isOpen: boolean; link: Link | null }>({
44
+
isOpen: false,
45
+
link: null,
46
+
});
47
const { toast } = useToast()
48
49
+
const fetchLinks = useCallback(async () => {
50
try {
51
setLoading(true)
52
const data = await getAllLinks()
53
setLinks(data)
54
+
} catch (err: unknown) {
55
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
56
toast({
57
title: "Error",
58
+
description: `Failed to load links: ${errorMessage}`,
59
variant: "destructive",
60
})
61
} finally {
62
setLoading(false)
63
}
64
+
}, [toast, setLinks, setLoading])
65
66
useEffect(() => {
67
fetchLinks()
68
+
}, [fetchLinks, refresh]) // Re-fetch when refresh counter changes
69
70
const handleDelete = async () => {
71
if (!deleteModal.linkId) return
···
77
toast({
78
description: "Link deleted successfully",
79
})
80
+
} catch (err: unknown) {
81
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
82
toast({
83
title: "Error",
84
+
description: `Failed to delete link: ${errorMessage}`,
85
variant: "destructive",
86
})
87
}
···
134
</CardHeader>
135
<CardContent>
136
<div className="rounded-md border">
137
+
138
<Table>
139
<TableHeader>
140
<TableRow>
···
142
<TableHead className="hidden md:table-cell">Original URL</TableHead>
143
<TableHead>Clicks</TableHead>
144
<TableHead className="hidden md:table-cell">Created</TableHead>
145
+
<TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead>
146
</TableRow>
147
</TableHeader>
148
<TableBody>
···
156
<TableCell className="hidden md:table-cell">
157
{new Date(link.created_at).toLocaleDateString()}
158
</TableCell>
159
+
<TableCell className="p-2 pr-4">
160
+
<div className="flex items-center gap-1">
161
<Button
162
variant="ghost"
163
size="icon"
···
179
<Button
180
variant="ghost"
181
size="icon"
182
+
className="h-8 w-8"
183
+
onClick={() => setEditModal({ isOpen: true, link })}
184
+
>
185
+
<Pencil className="h-4 w-4" />
186
+
<span className="sr-only">Edit Link</span>
187
+
</Button>
188
+
<Button
189
+
variant="ghost"
190
+
size="icon"
191
className="h-8 w-8 text-destructive"
192
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
193
>
···
203
</div>
204
</CardContent>
205
</Card>
206
+
<StatisticsModal
207
+
isOpen={statsModal.isOpen}
208
+
onClose={() => setStatsModal({ isOpen: false, linkId: null })}
209
+
linkId={statsModal.linkId!}
210
+
/>
211
+
{editModal.link && (
212
+
<EditModal
213
+
isOpen={editModal.isOpen}
214
+
onClose={() => setEditModal({ isOpen: false, link: null })}
215
+
link={editModal.link}
216
+
onSuccess={fetchLinks}
217
+
/>
218
)}
219
</>
220
)
+21
-9
frontend/src/components/StatisticsModal.tsx
+21
-9
frontend/src/components/StatisticsModal.tsx
···
10
} from "recharts";
11
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
12
import { toast } from "@/hooks/use-toast";
13
-
import { useState, useEffect } from "react";
14
15
import { getLinkClickStats, getLinkSourceStats } from "../api/client";
16
import { ClickStats, SourceStats } from "../types/api";
···
31
label,
32
}: {
33
active?: boolean;
34
-
payload?: any[];
35
label?: string;
36
}) => {
37
if (active && payload && payload.length > 0) {
···
58
return null;
59
};
60
61
-
export default function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) {
62
const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]);
63
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
64
const [loading, setLoading] = useState(true);
···
81
82
setClicksOverTime(enhancedClicksData);
83
setSourcesData(sourcesData);
84
-
} catch (error: any) {
85
console.error("Failed to fetch statistics:", error);
86
toast({
87
variant: "destructive",
88
title: "Error",
89
-
description: error.response?.data || "Failed to load statistics",
90
});
91
} finally {
92
setLoading(false);
···
97
}
98
}, [isOpen, linkId]);
99
100
return (
101
<Dialog open={isOpen} onOpenChange={onClose}>
102
<DialogContent className="max-w-3xl">
···
138
</CardHeader>
139
<CardContent>
140
<ul className="space-y-2">
141
-
{sourcesData.map((source, index) => (
142
<li
143
key={source.source}
144
className="flex items-center justify-between py-2 border-b last:border-0"
···
149
</span>
150
{source.source}
151
</span>
152
-
<span className="text-sm font-medium">
153
-
{source.count} clicks
154
-
</span>
155
</li>
156
))}
157
</ul>
···
10
} from "recharts";
11
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
12
import { toast } from "@/hooks/use-toast";
13
+
import { useState, useEffect, useMemo } from "react";
14
15
import { getLinkClickStats, getLinkSourceStats } from "../api/client";
16
import { ClickStats, SourceStats } from "../types/api";
···
31
label,
32
}: {
33
active?: boolean;
34
+
payload?: { value: number; payload: EnhancedClickStats }[];
35
label?: string;
36
}) => {
37
if (active && payload && payload.length > 0) {
···
58
return null;
59
};
60
61
+
export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) {
62
const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]);
63
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
64
const [loading, setLoading] = useState(true);
···
81
82
setClicksOverTime(enhancedClicksData);
83
setSourcesData(sourcesData);
84
+
} catch (error: unknown) {
85
console.error("Failed to fetch statistics:", error);
86
toast({
87
variant: "destructive",
88
title: "Error",
89
+
description: error instanceof Error ? error.message : "Failed to load statistics",
90
});
91
} finally {
92
setLoading(false);
···
97
}
98
}, [isOpen, linkId]);
99
100
+
const aggregatedSources = useMemo(() => {
101
+
const sourceMap = sourcesData.reduce<Record<string, number>>(
102
+
(acc, { source, count }) => ({
103
+
...acc,
104
+
[source]: (acc[source] || 0) + count
105
+
}),
106
+
{}
107
+
);
108
+
109
+
return Object.entries(sourceMap)
110
+
.map(([source, count]) => ({ source, count }))
111
+
.sort((a, b) => b.count - a.count);
112
+
}, [sourcesData]);
113
+
114
return (
115
<Dialog open={isOpen} onOpenChange={onClose}>
116
<DialogContent className="max-w-3xl">
···
152
</CardHeader>
153
<CardContent>
154
<ul className="space-y-2">
155
+
{aggregatedSources.map((source, index) => (
156
<li
157
key={source.source}
158
className="flex items-center justify-between py-2 border-b last:border-0"
···
163
</span>
164
{source.source}
165
</span>
166
+
<span className="text-sm font-medium">{source.count} clicks</span>
167
</li>
168
))}
169
</ul>
+28
-15
frontend/vite.config.ts
+28
-15
frontend/vite.config.ts
···
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
-
},
15
-
},*/
16
-
resolve: {
17
-
alias: {
18
-
"@": path.resolve(__dirname, "./src"),
19
-
},
20
-
},
21
-
}))
···
3
import tailwindcss from '@tailwindcss/vite'
4
import path from "path"
5
6
+
export default defineConfig(({ command }) => {
7
+
if (command === 'serve') { //command == 'dev'
8
+
return {
9
+
server: {
10
+
proxy: {
11
+
'/api': {
12
+
target: process.env.VITE_API_URL || 'http://localhost:8080',
13
+
changeOrigin: true,
14
+
},
15
+
},
16
+
},
17
+
plugins: [react(), tailwindcss()],
18
+
resolve: {
19
+
alias: {
20
+
"@": path.resolve(__dirname, "./src"),
21
+
},
22
+
},
23
+
}
24
+
} else { //command === 'build'
25
+
return {
26
+
plugins: [react(), tailwindcss()],
27
+
resolve: {
28
+
alias: {
29
+
"@": path.resolve(__dirname, "./src"),
30
+
},
31
},
32
+
}
33
+
}
34
+
})
+3
migrations/20250219000000_extend_short_code.sql
+3
migrations/20250219000000_extend_short_code.sql
+8
-7
src/auth.rs
+8
-7
src/auth.rs
···
1
use actix_web::{dev::Payload, FromRequest, HttpRequest};
2
use jsonwebtoken::{decode, DecodingKey, Validation};
3
use std::future::{ready, Ready};
4
-
use crate::{error::AppError, models::Claims};
5
6
pub struct AuthenticatedUser {
7
pub user_id: i32,
···
12
type Future = Ready<Result<Self, Self::Error>>;
13
14
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
15
-
let auth_header = req.headers()
16
.get("Authorization")
17
.and_then(|h| h.to_str().ok());
18
19
if let Some(auth_header) = auth_header {
20
if auth_header.starts_with("Bearer ") {
21
let token = &auth_header[7..];
22
-
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
23
-
24
match decode::<Claims>(
25
token,
26
&DecodingKey::from_secret(secret.as_bytes()),
27
-
&Validation::default()
28
) {
29
Ok(token_data) => {
30
return ready(Ok(AuthenticatedUser {
···
35
}
36
}
37
}
38
-
39
ready(Err(AppError::Unauthorized))
40
}
41
-
}
···
1
+
use crate::{error::AppError, models::Claims};
2
use actix_web::{dev::Payload, FromRequest, HttpRequest};
3
use jsonwebtoken::{decode, DecodingKey, Validation};
4
use std::future::{ready, Ready};
5
6
pub struct AuthenticatedUser {
7
pub user_id: i32,
···
12
type Future = Ready<Result<Self, Self::Error>>;
13
14
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
15
+
let auth_header = req
16
+
.headers()
17
.get("Authorization")
18
.and_then(|h| h.to_str().ok());
19
20
if let Some(auth_header) = auth_header {
21
if auth_header.starts_with("Bearer ") {
22
let token = &auth_header[7..];
23
+
let secret =
24
+
std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
25
match decode::<Claims>(
26
token,
27
&DecodingKey::from_secret(secret.as_bytes()),
28
+
&Validation::default(),
29
) {
30
Ok(token_data) => {
31
return ready(Ok(AuthenticatedUser {
···
36
}
37
}
38
}
39
ready(Err(AppError::Unauthorized))
40
}
41
+
}
42
+
+139
-6
src/handlers.rs
+139
-6
src/handlers.rs
···
131
Ok(())
132
}
133
134
-
fn validate_url(url: &String) -> Result<(), AppError> {
135
if url.is_empty() {
136
return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
137
}
···
457
}))
458
}
459
460
pub async fn delete_link(
461
state: web::Data<AppState>,
462
user: AuthenticatedUser,
463
path: web::Path<i32>,
464
) -> Result<impl Responder, AppError> {
465
-
let link_id = path.into_inner();
466
467
match &state.db {
468
DatabasePool::Postgres(pool) => {
···
570
WHERE link_id = $1
571
GROUP BY DATE(created_at)
572
ORDER BY DATE(created_at) ASC
573
-
LIMIT 30
574
"#,
575
)
576
.bind(link_id)
···
587
WHERE link_id = ?
588
GROUP BY DATE(created_at)
589
ORDER BY DATE(created_at) ASC
590
-
LIMIT 30
591
"#,
592
)
593
.bind(link_id)
···
652
AND query_source != ''
653
GROUP BY DATE(created_at), query_source
654
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
655
-
LIMIT 300
656
"#,
657
)
658
.bind(link_id)
···
672
AND query_source != ''
673
GROUP BY DATE(created_at), query_source
674
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
675
-
LIMIT 300
676
"#,
677
)
678
.bind(link_id)
···
131
Ok(())
132
}
133
134
+
fn validate_url(url: &str) -> Result<(), AppError> {
135
if url.is_empty() {
136
return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
137
}
···
457
}))
458
}
459
460
+
pub async fn edit_link(
461
+
state: web::Data<AppState>,
462
+
user: AuthenticatedUser,
463
+
path: web::Path<i32>,
464
+
payload: web::Json<CreateLink>,
465
+
) -> Result<impl Responder, AppError> {
466
+
let link_id: i32 = path.into_inner();
467
+
468
+
// Validate the new URL if provided
469
+
validate_url(&payload.url)?;
470
+
471
+
// Validate custom code if provided
472
+
if let Some(ref custom_code) = payload.custom_code {
473
+
validate_custom_code(custom_code)?;
474
+
475
+
// Check if the custom code is already taken by another link
476
+
let existing_link = match &state.db {
477
+
DatabasePool::Postgres(pool) => {
478
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = $1 AND id != $2")
479
+
.bind(custom_code)
480
+
.bind(link_id)
481
+
.fetch_optional(pool)
482
+
.await?
483
+
}
484
+
DatabasePool::Sqlite(pool) => {
485
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE short_code = ?1 AND id != ?2")
486
+
.bind(custom_code)
487
+
.bind(link_id)
488
+
.fetch_optional(pool)
489
+
.await?
490
+
}
491
+
};
492
+
493
+
if existing_link.is_some() {
494
+
return Err(AppError::InvalidInput(
495
+
"Custom code already taken".to_string(),
496
+
));
497
+
}
498
+
}
499
+
500
+
// Update the link
501
+
let updated_link = match &state.db {
502
+
DatabasePool::Postgres(pool) => {
503
+
let mut tx = pool.begin().await?;
504
+
505
+
// First verify the link belongs to the user
506
+
let link =
507
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = $1 AND user_id = $2")
508
+
.bind(link_id)
509
+
.bind(user.user_id)
510
+
.fetch_optional(&mut *tx)
511
+
.await?;
512
+
513
+
if link.is_none() {
514
+
return Err(AppError::NotFound);
515
+
}
516
+
517
+
// Update the link
518
+
let updated = sqlx::query_as::<_, Link>(
519
+
r#"
520
+
UPDATE links
521
+
SET
522
+
original_url = $1,
523
+
short_code = COALESCE($2, short_code)
524
+
WHERE id = $3 AND user_id = $4
525
+
RETURNING *
526
+
"#,
527
+
)
528
+
.bind(&payload.url)
529
+
.bind(&payload.custom_code)
530
+
.bind(link_id)
531
+
.bind(user.user_id)
532
+
.fetch_one(&mut *tx)
533
+
.await?;
534
+
535
+
// If source is provided, add a click record
536
+
if let Some(ref source) = payload.source {
537
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES ($1, $2)")
538
+
.bind(link_id)
539
+
.bind(source)
540
+
.execute(&mut *tx)
541
+
.await?;
542
+
}
543
+
544
+
tx.commit().await?;
545
+
updated
546
+
}
547
+
DatabasePool::Sqlite(pool) => {
548
+
let mut tx = pool.begin().await?;
549
+
550
+
// First verify the link belongs to the user
551
+
let link =
552
+
sqlx::query_as::<_, Link>("SELECT * FROM links WHERE id = ?1 AND user_id = ?2")
553
+
.bind(link_id)
554
+
.bind(user.user_id)
555
+
.fetch_optional(&mut *tx)
556
+
.await?;
557
+
558
+
if link.is_none() {
559
+
return Err(AppError::NotFound);
560
+
}
561
+
562
+
// Update the link
563
+
let updated = sqlx::query_as::<_, Link>(
564
+
r#"
565
+
UPDATE links
566
+
SET
567
+
original_url = ?1,
568
+
short_code = COALESCE(?2, short_code)
569
+
WHERE id = ?3 AND user_id = ?4
570
+
RETURNING *
571
+
"#,
572
+
)
573
+
.bind(&payload.url)
574
+
.bind(&payload.custom_code)
575
+
.bind(link_id)
576
+
.bind(user.user_id)
577
+
.fetch_one(&mut *tx)
578
+
.await?;
579
+
580
+
// If source is provided, add a click record
581
+
if let Some(ref source) = payload.source {
582
+
sqlx::query("INSERT INTO clicks (link_id, source) VALUES (?1, ?2)")
583
+
.bind(link_id)
584
+
.bind(source)
585
+
.execute(&mut *tx)
586
+
.await?;
587
+
}
588
+
589
+
tx.commit().await?;
590
+
updated
591
+
}
592
+
};
593
+
594
+
Ok(HttpResponse::Ok().json(updated_link))
595
+
}
596
+
597
pub async fn delete_link(
598
state: web::Data<AppState>,
599
user: AuthenticatedUser,
600
path: web::Path<i32>,
601
) -> Result<impl Responder, AppError> {
602
+
let link_id: i32 = path.into_inner();
603
604
match &state.db {
605
DatabasePool::Postgres(pool) => {
···
707
WHERE link_id = $1
708
GROUP BY DATE(created_at)
709
ORDER BY DATE(created_at) ASC
710
"#,
711
)
712
.bind(link_id)
···
723
WHERE link_id = ?
724
GROUP BY DATE(created_at)
725
ORDER BY DATE(created_at) ASC
726
"#,
727
)
728
.bind(link_id)
···
787
AND query_source != ''
788
GROUP BY DATE(created_at), query_source
789
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
790
"#,
791
)
792
.bind(link_id)
···
806
AND query_source != ''
807
GROUP BY DATE(created_at), query_source
808
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
809
"#,
810
)
811
.bind(link_id)
+159
-1
src/main.rs
+159
-1
src/main.rs
···
1
use actix_cors::Cors;
2
use actix_web::{web, App, HttpResponse, HttpServer};
3
use anyhow::Result;
4
use rust_embed::RustEmbed;
5
use simplelink::check_and_generate_admin_token;
6
use simplelink::{create_db_pool, run_migrations};
7
use simplelink::{handlers, AppState};
8
-
use tracing::info;
9
10
#[derive(RustEmbed)]
11
#[folder = "static/"]
12
struct Asset;
···
23
}
24
}
25
26
#[actix_web::main]
27
async fn main() -> Result<()> {
28
// Load environment variables from .env file
···
35
let pool = create_db_pool().await?;
36
run_migrations(&pool).await?;
37
38
let admin_token = check_and_generate_admin_token(&pool).await?;
39
40
let state = AppState {
···
70
"/links/{id}/sources",
71
web::get().to(handlers::get_link_sources),
72
)
73
.route("/auth/register", web::post().to(handlers::register))
74
.route("/auth/login", web::post().to(handlers::login))
75
.route(
···
1
use actix_cors::Cors;
2
use actix_web::{web, App, HttpResponse, HttpServer};
3
use anyhow::Result;
4
+
use clap::Parser;
5
use rust_embed::RustEmbed;
6
use simplelink::check_and_generate_admin_token;
7
+
use simplelink::models::DatabasePool;
8
use simplelink::{create_db_pool, run_migrations};
9
use simplelink::{handlers, AppState};
10
+
use sqlx::{Postgres, Sqlite};
11
+
use tracing::{error, info};
12
13
+
#[derive(Parser, Debug)]
14
+
#[command(author, version, about, long_about = None)]
15
#[derive(RustEmbed)]
16
#[folder = "static/"]
17
struct Asset;
···
28
}
29
}
30
31
+
async fn create_initial_links(pool: &DatabasePool) -> Result<()> {
32
+
if let Ok(links) = std::env::var("INITIAL_LINKS") {
33
+
for link_entry in links.split(';') {
34
+
let parts: Vec<&str> = link_entry.split(',').collect();
35
+
if parts.len() >= 2 {
36
+
let url = parts[0];
37
+
let code = parts[1];
38
+
39
+
match pool {
40
+
DatabasePool::Postgres(pool) => {
41
+
sqlx::query(
42
+
"INSERT INTO links (original_url, short_code, user_id)
43
+
VALUES ($1, $2, $3)
44
+
ON CONFLICT (short_code)
45
+
DO UPDATE SET short_code = EXCLUDED.short_code
46
+
WHERE links.original_url = EXCLUDED.original_url",
47
+
)
48
+
.bind(url)
49
+
.bind(code)
50
+
.bind(1)
51
+
.execute(pool)
52
+
.await?;
53
+
}
54
+
DatabasePool::Sqlite(pool) => {
55
+
// First check if the exact combination exists
56
+
let exists = sqlx::query_scalar::<_, bool>(
57
+
"SELECT EXISTS(
58
+
SELECT 1 FROM links
59
+
WHERE original_url = ?1
60
+
AND short_code = ?2
61
+
)",
62
+
)
63
+
.bind(url)
64
+
.bind(code)
65
+
.fetch_one(pool)
66
+
.await?;
67
+
68
+
// Only insert if the exact combination doesn't exist
69
+
if !exists {
70
+
sqlx::query(
71
+
"INSERT INTO links (original_url, short_code, user_id)
72
+
VALUES (?1, ?2, ?3)",
73
+
)
74
+
.bind(url)
75
+
.bind(code)
76
+
.bind(1)
77
+
.execute(pool)
78
+
.await?;
79
+
info!("Created initial link: {} -> {} for user_id: 1", code, url);
80
+
} else {
81
+
info!("Skipped existing link: {} -> {} for user_id: 1", code, url);
82
+
}
83
+
}
84
+
}
85
+
}
86
+
}
87
+
}
88
+
Ok(())
89
+
}
90
+
91
+
async fn create_admin_user(pool: &DatabasePool, email: &str, password: &str) -> Result<()> {
92
+
use argon2::{
93
+
password_hash::{rand_core::OsRng, SaltString},
94
+
Argon2, PasswordHasher,
95
+
};
96
+
97
+
let salt = SaltString::generate(&mut OsRng);
98
+
let argon2 = Argon2::default();
99
+
let password_hash = argon2
100
+
.hash_password(password.as_bytes(), &salt)
101
+
.map_err(|e| anyhow::anyhow!("Password hashing error: {}", e))?
102
+
.to_string();
103
+
104
+
match pool {
105
+
DatabasePool::Postgres(pool) => {
106
+
sqlx::query(
107
+
"INSERT INTO users (email, password_hash)
108
+
VALUES ($1, $2)
109
+
ON CONFLICT (email) DO NOTHING",
110
+
)
111
+
.bind(email)
112
+
.bind(&password_hash)
113
+
.execute(pool)
114
+
.await?;
115
+
}
116
+
DatabasePool::Sqlite(pool) => {
117
+
sqlx::query(
118
+
"INSERT OR IGNORE INTO users (email, password_hash)
119
+
VALUES (?1, ?2)",
120
+
)
121
+
.bind(email)
122
+
.bind(&password_hash)
123
+
.execute(pool)
124
+
.await?;
125
+
}
126
+
}
127
+
info!("Created admin user: {}", email);
128
+
Ok(())
129
+
}
130
+
131
#[actix_web::main]
132
async fn main() -> Result<()> {
133
// Load environment variables from .env file
···
140
let pool = create_db_pool().await?;
141
run_migrations(&pool).await?;
142
143
+
// First check if admin credentials are provided in environment variables
144
+
let admin_credentials = match (
145
+
std::env::var("SIMPLELINK_USER"),
146
+
std::env::var("SIMPLELINK_PASS"),
147
+
) {
148
+
(Ok(user), Ok(pass)) => Some((user, pass)),
149
+
_ => None,
150
+
};
151
+
152
+
if let Some((email, password)) = admin_credentials {
153
+
// Now check for existing users
154
+
let user_count = match &pool {
155
+
DatabasePool::Postgres(pool) => {
156
+
let mut tx = pool.begin().await?;
157
+
let count =
158
+
sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users")
159
+
.fetch_one(&mut *tx)
160
+
.await?
161
+
.0;
162
+
tx.commit().await?;
163
+
count
164
+
}
165
+
DatabasePool::Sqlite(pool) => {
166
+
let mut tx = pool.begin().await?;
167
+
let count = sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users")
168
+
.fetch_one(&mut *tx)
169
+
.await?
170
+
.0;
171
+
tx.commit().await?;
172
+
count
173
+
}
174
+
};
175
+
176
+
if user_count == 0 {
177
+
info!("No users found, creating admin user: {}", email);
178
+
match create_admin_user(&pool, &email, &password).await {
179
+
Ok(_) => info!("Successfully created admin user"),
180
+
Err(e) => {
181
+
error!("Failed to create admin user: {}", e);
182
+
return Err(anyhow::anyhow!("Failed to create admin user: {}", e));
183
+
}
184
+
}
185
+
}
186
+
} else {
187
+
info!(
188
+
"No admin credentials provided in environment variables, skipping admin user creation"
189
+
);
190
+
}
191
+
192
+
// Create initial links from environment variables
193
+
create_initial_links(&pool).await?;
194
+
195
let admin_token = check_and_generate_admin_token(&pool).await?;
196
197
let state = AppState {
···
227
"/links/{id}/sources",
228
web::get().to(handlers::get_link_sources),
229
)
230
+
.route("/links/{id}", web::patch().to(handlers::edit_link))
231
.route("/auth/register", web::post().to(handlers::register))
232
.route("/auth/login", web::post().to(handlers::login))
233
.route(
+1
-1
src/models.rs
+1
-1
src/models.rs