+2
-2
.github/workflows/docker-image.yml
+2
-2
.github/workflows/docker-image.yml
···
29
29
30
30
- name: Install cosign
31
31
if: github.event_name != 'pull_request'
32
-
uses: sigstore/cosign-installer@v3.7.0
32
+
uses: sigstore/cosign-installer@v3.8.1
33
33
with:
34
-
cosign-release: "v2.4.1"
34
+
cosign-release: "v2.4.3"
35
35
36
36
- name: Setup Docker buildx
37
37
uses: docker/setup-buildx-action@v3
+9
-39
Cargo.lock
+9
-39
Cargo.lock
···
1440
1440
1441
1441
[[package]]
1442
1442
name = "nu-ansi-term"
1443
-
version = "0.46.0"
1443
+
version = "0.50.1"
1444
1444
source = "registry+https://github.com/rust-lang/crates.io-index"
1445
-
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
1445
+
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
1446
1446
dependencies = [
1447
-
"overload",
1448
-
"winapi",
1447
+
"windows-sys 0.52.0",
1449
1448
]
1450
1449
1451
1450
[[package]]
···
1525
1524
version = "1.20.2"
1526
1525
source = "registry+https://github.com/rust-lang/crates.io-index"
1527
1526
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
1527
1535
1528
[[package]]
1536
1529
name = "parking"
···
1751
1744
1752
1745
[[package]]
1753
1746
name = "ring"
1754
-
version = "0.17.8"
1747
+
version = "0.17.13"
1755
1748
source = "registry+https://github.com/rust-lang/crates.io-index"
1756
-
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
1749
+
checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee"
1757
1750
dependencies = [
1758
1751
"cc",
1759
1752
"cfg-if",
1760
1753
"getrandom",
1761
1754
"libc",
1762
-
"spin",
1763
1755
"untrusted",
1764
1756
"windows-sys 0.52.0",
1765
1757
]
···
2432
2424
2433
2425
[[package]]
2434
2426
name = "tokio"
2435
-
version = "1.43.0"
2427
+
version = "1.43.1"
2436
2428
source = "registry+https://github.com/rust-lang/crates.io-index"
2437
-
checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e"
2429
+
checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c"
2438
2430
dependencies = [
2439
2431
"backtrace",
2440
2432
"bytes",
···
2529
2521
2530
2522
[[package]]
2531
2523
name = "tracing-subscriber"
2532
-
version = "0.3.19"
2524
+
version = "0.3.20"
2533
2525
source = "registry+https://github.com/rust-lang/crates.io-index"
2534
-
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
2526
+
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
2535
2527
dependencies = [
2536
2528
"nu-ansi-term",
2537
2529
"sharded-slab",
···
2739
2731
]
2740
2732
2741
2733
[[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
2734
name = "winapi-util"
2759
2735
version = "0.1.9"
2760
2736
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2762
2738
dependencies = [
2763
2739
"windows-sys 0.59.0",
2764
2740
]
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
2741
2772
2742
[[package]]
2773
2743
name = "windows-core"
+1
-1
Cargo.toml
+1
-1
Cargo.toml
···
13
13
actix-web = "4.4"
14
14
actix-files = "0.6"
15
15
actix-cors = "0.6"
16
-
tokio = { version = "1.36", features = ["rt-multi-thread", "macros"] }
16
+
tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] }
17
17
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "sqlite", "chrono"] }
18
18
serde = { version = "1.0", features = ["derive"] }
19
19
serde_json = "1.0"
+1
-1
Dockerfile
+1
-1
Dockerfile
+29
-13
README.md
+29
-13
README.md
···
1
1
# SimpleLink
2
2
3
-
A very performant and light (2MB in memory) link shortener and tracker. Written in Rust and React and uses Postgres.
3
+
A very performant and light (2MB in memory) link shortener and tracker. Written in Rust and React and uses Postgres or SQLite.
4
4
5
5

6
6
···
8
8
9
9
## How to Run
10
10
11
-
### From Docker:
11
+
### From Docker
12
12
13
-
```Bash
13
+
```bash
14
14
docker run -p 8080:8080 \
15
15
-e JWT_SECRET=change-me-in-production \
16
+
-e SIMPLELINK_USER=admin@example.com \
17
+
-e SIMPLELINK_PASS=your-secure-password \
16
18
-v simplelink_data:/data \
17
-
ghcr.io/waveringana/simplelink:v2
19
+
ghcr.io/waveringana/simplelink:v2.2
18
20
```
19
21
20
-
Find the admin-setup-token pasted into the terminal output, or in admin-setup-token.txt in the container's root.
22
+
### Environment Variables
21
23
22
-
This is needed to register with the frontend. (TODO, register admin account with ENV)
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"
23
31
24
-
### From Docker Compose:
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
25
35
26
-
Edit the docker-compose.yml file. It comes included with a postgressql db for use
36
+
Edit the docker-compose.yml file. It comes included with a PostgreSQL db configuration.
27
37
28
38
## Build
29
39
···
31
41
32
42
First configure .env.example and save it to .env
33
43
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
44
```bash
37
45
git clone https://github.com/waveringana/simplelink && cd simplelink
38
46
./build.sh
39
47
cargo run
40
48
```
41
49
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
50
+
Alternatively for a binary build:
45
51
46
52
```bash
47
53
./build.sh --binary
···
55
61
docker build -t simplelink .
56
62
docker run -p 8080:8080 \
57
63
-e JWT_SECRET=change-me-in-production \
64
+
-e SIMPLELINK_USER=admin@example.com \
65
+
-e SIMPLELINK_PASS=your-secure-password \
58
66
-v simplelink_data:/data \
59
67
simplelink
60
68
```
···
62
70
### From Docker Compose
63
71
64
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
-1
docker-compose.yml
+1
-1
docker-compose.yml
+6
frontend/src/api/client.ts
+6
frontend/src/api/client.ts
···
58
58
return response.data;
59
59
};
60
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
+
61
67
export const deleteLink = async (id: number) => {
62
68
await api.delete(`/links/${id}`);
63
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
+
}
+44
-19
frontend/src/components/LinkList.tsx
+44
-19
frontend/src/components/LinkList.tsx
···
1
-
import { useEffect, useState } from 'react'
1
+
import { useCallback, useEffect, useState } from 'react'
2
2
import { Link } from '../types/api'
3
3
import { getAllLinks, deleteLink } from '../api/client'
4
4
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
···
12
12
} from "@/components/ui/table"
13
13
import { Button } from "@/components/ui/button"
14
14
import { useToast } from "@/hooks/use-toast"
15
-
import { Copy, Trash2, BarChart2 } from "lucide-react"
15
+
import { Copy, Trash2, BarChart2, Pencil } from "lucide-react"
16
16
import {
17
17
Dialog,
18
18
DialogContent,
···
23
23
} from "@/components/ui/dialog"
24
24
25
25
import { StatisticsModal } from "./StatisticsModal"
26
+
import { EditModal } from './EditModal'
26
27
27
28
interface LinkListProps {
28
29
refresh?: number;
···
39
40
isOpen: false,
40
41
linkId: null,
41
42
});
43
+
const [editModal, setEditModal] = useState<{ isOpen: boolean; link: Link | null }>({
44
+
isOpen: false,
45
+
link: null,
46
+
});
42
47
const { toast } = useToast()
43
48
44
-
const fetchLinks = async () => {
49
+
const fetchLinks = useCallback(async () => {
45
50
try {
46
51
setLoading(true)
47
52
const data = await getAllLinks()
48
53
setLinks(data)
49
-
} catch (err) {
54
+
} catch (err: unknown) {
55
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
50
56
toast({
51
57
title: "Error",
52
-
description: "Failed to load links",
58
+
description: `Failed to load links: ${errorMessage}`,
53
59
variant: "destructive",
54
60
})
55
61
} finally {
56
62
setLoading(false)
57
63
}
58
-
}
64
+
}, [toast, setLinks, setLoading])
59
65
60
66
useEffect(() => {
61
67
fetchLinks()
62
-
}, [refresh]) // Re-fetch when refresh counter changes
68
+
}, [fetchLinks, refresh]) // Re-fetch when refresh counter changes
63
69
64
70
const handleDelete = async () => {
65
71
if (!deleteModal.linkId) return
···
71
77
toast({
72
78
description: "Link deleted successfully",
73
79
})
74
-
} catch (err) {
80
+
} catch (err: unknown) {
81
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
75
82
toast({
76
83
title: "Error",
77
-
description: "Failed to delete link",
84
+
description: `Failed to delete link: ${errorMessage}`,
78
85
variant: "destructive",
79
86
})
80
87
}
···
85
92
const baseUrl = window.location.origin
86
93
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
87
94
toast({
88
-
description: (
89
-
<>
90
-
Link copied to clipboard
91
-
<br />
92
-
You can add ?source=TextHere to the end of the link to track the source of clicks
93
-
</>
94
-
),
95
+
description: (
96
+
<>
97
+
Link copied to clipboard
98
+
<br />
99
+
You can add ?source=TextHere to the end of the link to track the source of clicks
100
+
</>
101
+
),
95
102
})
96
103
}
97
104
···
127
134
</CardHeader>
128
135
<CardContent>
129
136
<div className="rounded-md border">
137
+
130
138
<Table>
131
139
<TableHeader>
132
140
<TableRow>
···
134
142
<TableHead className="hidden md:table-cell">Original URL</TableHead>
135
143
<TableHead>Clicks</TableHead>
136
144
<TableHead className="hidden md:table-cell">Created</TableHead>
137
-
<TableHead>Actions</TableHead>
145
+
<TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead>
138
146
</TableRow>
139
147
</TableHeader>
140
148
<TableBody>
···
148
156
<TableCell className="hidden md:table-cell">
149
157
{new Date(link.created_at).toLocaleDateString()}
150
158
</TableCell>
151
-
<TableCell>
152
-
<div className="flex gap-2">
159
+
<TableCell className="p-2 pr-4">
160
+
<div className="flex items-center gap-1">
153
161
<Button
154
162
variant="ghost"
155
163
size="icon"
···
171
179
<Button
172
180
variant="ghost"
173
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"
174
191
className="h-8 w-8 text-destructive"
175
192
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
176
193
>
···
191
208
onClose={() => setStatsModal({ isOpen: false, linkId: null })}
192
209
linkId={statsModal.linkId!}
193
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
+
)}
194
219
</>
195
220
)
196
221
}
+3
-3
frontend/src/components/StatisticsModal.tsx
+3
-3
frontend/src/components/StatisticsModal.tsx
···
31
31
label,
32
32
}: {
33
33
active?: boolean;
34
-
payload?: any[];
34
+
payload?: { value: number; payload: EnhancedClickStats }[];
35
35
label?: string;
36
36
}) => {
37
37
if (active && payload && payload.length > 0) {
···
81
81
82
82
setClicksOverTime(enhancedClicksData);
83
83
setSourcesData(sourcesData);
84
-
} catch (error: any) {
84
+
} catch (error: unknown) {
85
85
console.error("Failed to fetch statistics:", error);
86
86
toast({
87
87
variant: "destructive",
88
88
title: "Error",
89
-
description: error.response?.data || "Failed to load statistics",
89
+
description: error instanceof Error ? error.message : "Failed to load statistics",
90
90
});
91
91
} finally {
92
92
setLoading(false);
+28
-15
frontend/vite.config.ts
+28
-15
frontend/vite.config.ts
···
3
3
import tailwindcss from '@tailwindcss/vite'
4
4
import path from "path"
5
5
6
-
export default defineConfig(() => ({
7
-
plugins: [react(), tailwindcss()],
8
-
/*server: {
9
-
proxy: {
10
-
'/api': {
11
-
target: process.env.VITE_API_URL || 'http://localhost:8080',
12
-
changeOrigin: true,
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
+
},
13
31
},
14
-
},
15
-
},*/
16
-
resolve: {
17
-
alias: {
18
-
"@": path.resolve(__dirname, "./src"),
19
-
},
20
-
},
21
-
}))
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 crate::{error::AppError, models::Claims};
1
2
use actix_web::{dev::Payload, FromRequest, HttpRequest};
2
3
use jsonwebtoken::{decode, DecodingKey, Validation};
3
4
use std::future::{ready, Ready};
4
-
use crate::{error::AppError, models::Claims};
5
5
6
6
pub struct AuthenticatedUser {
7
7
pub user_id: i32,
···
12
12
type Future = Ready<Result<Self, Self::Error>>;
13
13
14
14
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
15
-
let auth_header = req.headers()
15
+
let auth_header = req
16
+
.headers()
16
17
.get("Authorization")
17
18
.and_then(|h| h.to_str().ok());
18
19
19
20
if let Some(auth_header) = auth_header {
20
21
if auth_header.starts_with("Bearer ") {
21
22
let token = &auth_header[7..];
22
-
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
23
-
23
+
let secret =
24
+
std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string());
24
25
match decode::<Claims>(
25
26
token,
26
27
&DecodingKey::from_secret(secret.as_bytes()),
27
-
&Validation::default()
28
+
&Validation::default(),
28
29
) {
29
30
Ok(token_data) => {
30
31
return ready(Ok(AuthenticatedUser {
···
35
36
}
36
37
}
37
38
}
38
-
39
39
ready(Err(AppError::Unauthorized))
40
40
}
41
-
}
41
+
}
42
+
+139
-6
src/handlers.rs
+139
-6
src/handlers.rs
···
131
131
Ok(())
132
132
}
133
133
134
-
fn validate_url(url: &String) -> Result<(), AppError> {
134
+
fn validate_url(url: &str) -> Result<(), AppError> {
135
135
if url.is_empty() {
136
136
return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
137
137
}
···
457
457
}))
458
458
}
459
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
+
460
597
pub async fn delete_link(
461
598
state: web::Data<AppState>,
462
599
user: AuthenticatedUser,
463
600
path: web::Path<i32>,
464
601
) -> Result<impl Responder, AppError> {
465
-
let link_id = path.into_inner();
602
+
let link_id: i32 = path.into_inner();
466
603
467
604
match &state.db {
468
605
DatabasePool::Postgres(pool) => {
···
570
707
WHERE link_id = $1
571
708
GROUP BY DATE(created_at)
572
709
ORDER BY DATE(created_at) ASC
573
-
LIMIT 30
574
710
"#,
575
711
)
576
712
.bind(link_id)
···
587
723
WHERE link_id = ?
588
724
GROUP BY DATE(created_at)
589
725
ORDER BY DATE(created_at) ASC
590
-
LIMIT 30
591
726
"#,
592
727
)
593
728
.bind(link_id)
···
652
787
AND query_source != ''
653
788
GROUP BY DATE(created_at), query_source
654
789
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
655
-
LIMIT 300
656
790
"#,
657
791
)
658
792
.bind(link_id)
···
672
806
AND query_source != ''
673
807
GROUP BY DATE(created_at), query_source
674
808
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
675
-
LIMIT 300
676
809
"#,
677
810
)
678
811
.bind(link_id)
+159
-1
src/main.rs
+159
-1
src/main.rs
···
1
1
use actix_cors::Cors;
2
2
use actix_web::{web, App, HttpResponse, HttpServer};
3
3
use anyhow::Result;
4
+
use clap::Parser;
4
5
use rust_embed::RustEmbed;
5
6
use simplelink::check_and_generate_admin_token;
7
+
use simplelink::models::DatabasePool;
6
8
use simplelink::{create_db_pool, run_migrations};
7
9
use simplelink::{handlers, AppState};
8
-
use tracing::info;
10
+
use sqlx::{Postgres, Sqlite};
11
+
use tracing::{error, info};
9
12
13
+
#[derive(Parser, Debug)]
14
+
#[command(author, version, about, long_about = None)]
10
15
#[derive(RustEmbed)]
11
16
#[folder = "static/"]
12
17
struct Asset;
···
23
28
}
24
29
}
25
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
+
26
131
#[actix_web::main]
27
132
async fn main() -> Result<()> {
28
133
// Load environment variables from .env file
···
35
140
let pool = create_db_pool().await?;
36
141
run_migrations(&pool).await?;
37
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
+
38
195
let admin_token = check_and_generate_admin_token(&pool).await?;
39
196
40
197
let state = AppState {
···
70
227
"/links/{id}/sources",
71
228
web::get().to(handlers::get_link_sources),
72
229
)
230
+
.route("/links/{id}", web::patch().to(handlers::edit_link))
73
231
.route("/auth/register", web::post().to(handlers::register))
74
232
.route("/auth/login", web::post().to(handlers::login))
75
233
.route(