+2
-1
.env.example
+2
-1
.env.example
···
1
-
DATABASE_URL=postgresql://user:password@localhost/dbname
1
+
# by default, simplelink uses an sqlite db in /data, to use a postgres db, set DATABASE_URl
2
+
# DATABASE_URL=postgresql://user:password@localhost/dbname
2
3
SERVER_HOST=127.0.0.1
3
4
SERVER_PORT=8080
4
5
JWT_SECRET=change-me-in-production
+5
-15
.github/workflows/docker-image.yml
+5
-15
.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
···
59
59
${{ env.IMAGE_NAME }}
60
60
${{ env.REGISTRY }}/${{ github.repository }}
61
61
62
-
- name: Build and push Docker image (amd64)
63
-
uses: docker/build-push-action@v6
64
-
with:
65
-
context: .
66
-
file: ./Dockerfile
67
-
platforms: linux/amd64
68
-
push: ${{ github.event_name != 'pull_request' }}
69
-
tags: ${{ steps.meta.outputs.tags }}-amd64
70
-
labels: ${{ steps.meta.outputs.labels }}
71
-
72
-
- name: Build and push Docker image (arm64)
62
+
- name: Build and push Docker image
73
63
uses: docker/build-push-action@v6
74
64
with:
75
65
context: .
76
66
file: ./Dockerfile
77
-
platforms: linux/arm64
67
+
platforms: linux/amd64,linux/arm64
78
68
push: ${{ github.event_name != 'pull_request' }}
79
-
tags: ${{ steps.meta.outputs.tags }}-arm64
69
+
tags: ${{ steps.meta.outputs.tags }}
80
70
labels: ${{ steps.meta.outputs.labels }}
-80
.github/workflows/main.yml
-80
.github/workflows/main.yml
···
1
-
name: Docker
2
-
3
-
on:
4
-
schedule:
5
-
- cron: "38 9 * * *"
6
-
push:
7
-
branches: ["main"]
8
-
tags: ["v*.*.*"]
9
-
pull_request:
10
-
branches: ["main"]
11
-
release:
12
-
types: [published]
13
-
14
-
env:
15
-
REGISTRY: ghcr.io
16
-
IMAGE_NAME: ${{ github.repository }}
17
-
18
-
jobs:
19
-
build:
20
-
runs-on: macos-latest
21
-
permissions:
22
-
contents: read
23
-
packages: write
24
-
id-token: write
25
-
26
-
steps:
27
-
- name: Checkout repository
28
-
uses: actions/checkout@v3
29
-
30
-
- name: Install cosign
31
-
if: github.event_name != 'pull_request'
32
-
uses: sigstore/cosign-installer@v3.7.0
33
-
with:
34
-
cosign-release: "v2.4.1"
35
-
36
-
- name: Setup Docker buildx
37
-
uses: docker/setup-buildx-action@v3
38
-
39
-
- name: Log into registry ${{ env.REGISTRY }}
40
-
if: github.event_name != 'pull_request'
41
-
uses: docker/login-action@v3
42
-
with:
43
-
registry: ${{ env.REGISTRY }}
44
-
username: ${{ github.actor }}
45
-
password: ${{ secrets.GITHUB_TOKEN }}
46
-
47
-
- name: Log in to Docker Hub
48
-
if: github.event_name != 'pull_request'
49
-
uses: docker/login-action@v3
50
-
with:
51
-
username: ${{ secrets.DOCKER_USERNAME }}
52
-
password: ${{ secrets.DOCKER_PASSWORD }}
53
-
54
-
- name: Extract metadata (tags, labels) for Docker
55
-
id: meta
56
-
uses: docker/metadata-action@v5
57
-
with:
58
-
images: |
59
-
${{ env.IMAGE_NAME }}
60
-
${{ env.REGISTRY }}/${{ github.repository }}
61
-
62
-
- name: Build and push Docker image (amd64)
63
-
uses: docker/build-push-action@v6
64
-
with:
65
-
context: .
66
-
file: ./Dockerfile
67
-
platforms: linux/amd64
68
-
push: ${{ github.event_name != 'pull_request' }}
69
-
tags: ${{ steps.meta.outputs.tags }}-amd64
70
-
labels: ${{ steps.meta.outputs.labels }}
71
-
72
-
- name: Build and push Docker image (arm64)
73
-
uses: docker/build-push-action@v6
74
-
with:
75
-
context: .
76
-
file: ./Dockerfile
77
-
platforms: linux/arm64
78
-
push: ${{ github.event_name != 'pull_request' }}
79
-
tags: ${{ steps.meta.outputs.tags }}-arm64
80
-
labels: ${{ steps.meta.outputs.labels }}
+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"
+2
-2
Dockerfile
+2
-2
Dockerfile
···
4
4
WORKDIR /usr/src/frontend
5
5
6
6
# Copy frontend files
7
-
COPY frontend/package*.json ./
7
+
COPY frontend/package.json ./
8
8
RUN bun install
9
9
10
10
COPY frontend/ ./
···
57
57
# Copy static files
58
58
COPY --from=backend-builder /usr/src/app/static /app/static
59
59
60
-
# Expose the port (this is just documentation)
60
+
# Expose the port
61
61
EXPOSE 8080
62
62
63
63
# Set default network configuration
+51
-11
README.md
+51
-11
README.md
···
1
1
# SimpleLink
2
-
A very performant and light (6mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres.
2
+
3
+
A very performant and light (2MB in memory) link shortener and tracker. Written in Rust and React and uses Postgres or SQLite.
3
4
4
5

5
6
6
7

7
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
+
8
38
## Build
9
39
10
40
### From Source
11
-
First configure .env.example and save it to .env
12
41
13
-
The project will not run withot DATABASE_URL set. (TODO add sqlite support)
42
+
First configure .env.example and save it to .env
14
43
15
44
```bash
16
-
#set api-domain to where you will be deploying the link shortener, eg: link.example.com, default is localhost:8080
17
45
git clone https://github.com/waveringana/simplelink && cd simplelink
18
-
./build.sh api-domain=localhost:8080
46
+
./build.sh
19
47
cargo run
20
48
```
21
49
22
-
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.
50
+
Alternatively for a binary build:
23
51
24
-
Alternatively if you want a binary form
25
52
```bash
26
53
./build.sh --binary
27
54
```
55
+
28
56
then check /target/release for the binary named `SimpleGit`
29
57
30
58
### From Docker
59
+
31
60
```bash
32
-
docker build --build-arg API_URL=http://localhost:8080 -t simplelink .
61
+
docker build -t simplelink .
33
62
docker run -p 8080:8080 \
34
-
-e JWT_SECRET=change-me-in-production \
35
-
-e DATABASE_URL=postgres://user:password@host:port/database \
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 \
36
67
simplelink
37
68
```
38
69
39
70
### From Docker Compose
40
-
Adjust the included docker-compose.yml to your liking, it includes a postgres config as well.
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
+7
-7
build.sh
+7
-7
build.sh
···
1
1
#!/bin/bash
2
2
3
3
# Default values
4
-
API_URL="http://localhost:8080"
4
+
#API_URL="http://localhost:8080"
5
5
RELEASE_MODE=false
6
6
BINARY_MODE=false
7
7
···
9
9
for arg in "$@"
10
10
do
11
11
case $arg in
12
-
api-domain=*)
13
-
API_URL="${arg#*=}"
14
-
shift
15
-
;;
12
+
#api-domain=*)
13
+
#API_URL="${arg#*=}"
14
+
#shift
15
+
#;;
16
16
--release)
17
17
RELEASE_MODE=true
18
18
shift
···
24
24
esac
25
25
done
26
26
27
-
echo "Building project with API_URL: $API_URL"
27
+
#echo "Building project with API_URL: $API_URL"
28
28
echo "Release mode: $RELEASE_MODE"
29
29
30
30
# Check if cargo is installed
···
42
42
# Build frontend
43
43
echo "Building frontend..."
44
44
# Create .env file for Vite
45
-
echo "VITE_API_URL=$API_URL" > frontend/.env
45
+
#echo "VITE_API_URL=$API_URL" > frontend/.env
46
46
47
47
# Install frontend dependencies and build
48
48
cd frontend
+1
-5
docker-compose.yml
+1
-5
docker-compose.yml
+21
-4
frontend/src/api/client.ts
+21
-4
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
};
64
70
65
71
export const getLinkClickStats = async (id: number) => {
66
-
const response = await api.get<ClickStats[]>(`/links/${id}/clicks`);
67
-
return response.data;
72
+
try {
73
+
const response = await api.get<ClickStats[]>(`/links/${id}/clicks`);
74
+
return response.data;
75
+
} catch (error) {
76
+
console.error('Error fetching click stats:', error);
77
+
throw error;
78
+
}
68
79
};
69
80
70
81
export const getLinkSourceStats = async (id: number) => {
71
-
const response = await api.get<SourceStats[]>(`/links/${id}/sources`);
72
-
return response.data;
82
+
try {
83
+
const response = await api.get<SourceStats[]>(`/links/${id}/sources`);
84
+
return response.data;
85
+
} catch (error) {
86
+
console.error('Error fetching source stats:', error);
87
+
throw error;
88
+
}
73
89
};
90
+
74
91
75
92
export const checkFirstUser = async () => {
76
93
const response = await api.get<{ isFirstUser: boolean }>('/auth/check-first-user');
+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
+
}
+45
-14
frontend/src/components/LinkList.tsx
+45
-14
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
}
···
82
89
83
90
const handleCopy = (shortCode: string) => {
84
91
// Use import.meta.env.VITE_BASE_URL or fall back to window.location.origin
85
-
const baseUrl = import.meta.env.VITE_API_URL || window.location.origin
92
+
const baseUrl = window.location.origin
86
93
navigator.clipboard.writeText(`${baseUrl}/${shortCode}`)
87
94
toast({
88
-
description: "Link copied to clipboard",
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
+
),
89
102
})
90
103
}
91
104
···
121
134
</CardHeader>
122
135
<CardContent>
123
136
<div className="rounded-md border">
137
+
124
138
<Table>
125
139
<TableHeader>
126
140
<TableRow>
···
128
142
<TableHead className="hidden md:table-cell">Original URL</TableHead>
129
143
<TableHead>Clicks</TableHead>
130
144
<TableHead className="hidden md:table-cell">Created</TableHead>
131
-
<TableHead>Actions</TableHead>
145
+
<TableHead className="w-[1%] whitespace-nowrap pr-4">Actions</TableHead>
132
146
</TableRow>
133
147
</TableHeader>
134
148
<TableBody>
···
142
156
<TableCell className="hidden md:table-cell">
143
157
{new Date(link.created_at).toLocaleDateString()}
144
158
</TableCell>
145
-
<TableCell>
146
-
<div className="flex gap-2">
159
+
<TableCell className="p-2 pr-4">
160
+
<div className="flex items-center gap-1">
147
161
<Button
148
162
variant="ghost"
149
163
size="icon"
···
165
179
<Button
166
180
variant="ghost"
167
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"
168
191
className="h-8 w-8 text-destructive"
169
192
onClick={() => setDeleteModal({ isOpen: true, linkId: link.id })}
170
193
>
···
185
208
onClose={() => setStatsModal({ isOpen: false, linkId: null })}
186
209
linkId={statsModal.linkId!}
187
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
+
)}
188
219
</>
189
220
)
190
221
}
+160
-98
frontend/src/components/StatisticsModal.tsx
+160
-98
frontend/src/components/StatisticsModal.tsx
···
1
1
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
2
2
import {
3
-
LineChart,
4
-
Line,
5
-
XAxis,
6
-
YAxis,
7
-
CartesianGrid,
8
-
Tooltip,
9
-
ResponsiveContainer,
3
+
LineChart,
4
+
Line,
5
+
XAxis,
6
+
YAxis,
7
+
CartesianGrid,
8
+
Tooltip,
9
+
ResponsiveContainer,
10
10
} from "recharts";
11
11
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
12
-
import { useState, useEffect } from "react";
12
+
import { toast } from "@/hooks/use-toast";
13
+
import { useState, useEffect, useMemo } from "react";
13
14
14
-
import { getLinkClickStats, getLinkSourceStats } from '../api/client';
15
-
import { ClickStats, SourceStats } from '../types/api';
15
+
import { getLinkClickStats, getLinkSourceStats } from "../api/client";
16
+
import { ClickStats, SourceStats } from "../types/api";
16
17
17
18
interface StatisticsModalProps {
18
-
isOpen: boolean;
19
-
onClose: () => void;
20
-
linkId: number;
19
+
isOpen: boolean;
20
+
onClose: () => void;
21
+
linkId: number;
21
22
}
22
23
24
+
interface EnhancedClickStats extends ClickStats {
25
+
sources?: { source: string; count: number }[];
26
+
}
27
+
28
+
const CustomTooltip = ({
29
+
active,
30
+
payload,
31
+
label,
32
+
}: {
33
+
active?: boolean;
34
+
payload?: { value: number; payload: EnhancedClickStats }[];
35
+
label?: string;
36
+
}) => {
37
+
if (active && payload && payload.length > 0) {
38
+
const data = payload[0].payload;
39
+
return (
40
+
<div className="bg-background text-foreground p-4 rounded-lg shadow-lg border">
41
+
<p className="font-medium">{label}</p>
42
+
<p className="text-sm">Clicks: {data.clicks}</p>
43
+
{data.sources && data.sources.length > 0 && (
44
+
<div className="mt-2">
45
+
<p className="font-medium text-sm">Sources:</p>
46
+
<ul className="text-sm">
47
+
{data.sources.map((source: { source: string; count: number }) => (
48
+
<li key={source.source}>
49
+
{source.source}: {source.count}
50
+
</li>
51
+
))}
52
+
</ul>
53
+
</div>
54
+
)}
55
+
</div>
56
+
);
57
+
}
58
+
return null;
59
+
};
60
+
23
61
export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) {
24
-
const [clicksOverTime, setClicksOverTime] = useState<ClickStats[]>([]);
25
-
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
26
-
const [loading, setLoading] = useState(true);
62
+
const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]);
63
+
const [sourcesData, setSourcesData] = useState<SourceStats[]>([]);
64
+
const [loading, setLoading] = useState(true);
27
65
28
-
useEffect(() => {
29
-
if (isOpen && linkId) {
30
-
const fetchData = async () => {
31
-
try {
32
-
setLoading(true);
33
-
const [clicksData, sourcesData] = await Promise.all([
34
-
getLinkClickStats(linkId),
35
-
getLinkSourceStats(linkId),
36
-
]);
37
-
setClicksOverTime(clicksData);
38
-
setSourcesData(sourcesData);
39
-
} catch (error) {
40
-
console.error("Failed to fetch statistics:", error);
41
-
} finally {
42
-
setLoading(false);
43
-
}
44
-
};
66
+
useEffect(() => {
67
+
if (isOpen && linkId) {
68
+
const fetchData = async () => {
69
+
try {
70
+
setLoading(true);
71
+
const [clicksData, sourcesData] = await Promise.all([
72
+
getLinkClickStats(linkId),
73
+
getLinkSourceStats(linkId),
74
+
]);
45
75
46
-
fetchData();
47
-
}
48
-
}, [isOpen, linkId]);
76
+
// Enhance clicks data with source information
77
+
const enhancedClicksData = clicksData.map((clickData) => ({
78
+
...clickData,
79
+
sources: sourcesData.filter((source) => source.date === clickData.date),
80
+
}));
49
81
50
-
return (
51
-
<Dialog open={isOpen} onOpenChange={onClose}>
52
-
<DialogContent className="max-w-3xl">
53
-
<DialogHeader>
54
-
<DialogTitle>Link Statistics</DialogTitle>
55
-
</DialogHeader>
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);
93
+
}
94
+
};
56
95
57
-
{loading ? (
58
-
<div className="flex items-center justify-center h-64">Loading...</div>
59
-
) : (
60
-
<div className="grid gap-4">
61
-
<Card>
62
-
<CardHeader>
63
-
<CardTitle>Clicks Over Time</CardTitle>
64
-
</CardHeader>
65
-
<CardContent>
66
-
<div className="h-[300px]">
67
-
<ResponsiveContainer width="100%" height="100%">
68
-
<LineChart data={clicksOverTime}>
69
-
<CartesianGrid strokeDasharray="3 3" />
70
-
<XAxis dataKey="date" />
71
-
<YAxis />
72
-
<Tooltip />
73
-
<Line
74
-
type="monotone"
75
-
dataKey="clicks"
76
-
stroke="#8884d8"
77
-
strokeWidth={2}
78
-
/>
79
-
</LineChart>
80
-
</ResponsiveContainer>
81
-
</div>
82
-
</CardContent>
83
-
</Card>
96
+
fetchData();
97
+
}
98
+
}, [isOpen, linkId]);
84
99
85
-
<Card>
86
-
<CardHeader>
87
-
<CardTitle>Top Sources</CardTitle>
88
-
</CardHeader>
89
-
<CardContent>
90
-
<ul className="space-y-2">
91
-
{sourcesData.map((source, index) => (
92
-
<li
93
-
key={source.source}
94
-
className="flex items-center justify-between py-2 border-b last:border-0"
95
-
>
96
-
<span className="text-sm">
97
-
<span className="font-medium text-muted-foreground mr-2">
98
-
{index + 1}.
99
-
</span>
100
-
{source.source}
101
-
</span>
102
-
<span className="text-sm font-medium">
103
-
{source.count} clicks
104
-
</span>
105
-
</li>
106
-
))}
107
-
</ul>
108
-
</CardContent>
109
-
</Card>
110
-
</div>
111
-
)}
112
-
</DialogContent>
113
-
</Dialog>
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
+
{}
114
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">
117
+
<DialogHeader>
118
+
<DialogTitle>Link Statistics</DialogTitle>
119
+
</DialogHeader>
120
+
121
+
{loading ? (
122
+
<div className="flex items-center justify-center h-64">Loading...</div>
123
+
) : (
124
+
<div className="grid gap-4">
125
+
<Card>
126
+
<CardHeader>
127
+
<CardTitle>Clicks Over Time</CardTitle>
128
+
</CardHeader>
129
+
<CardContent>
130
+
<div className="h-[300px]">
131
+
<ResponsiveContainer width="100%" height="100%">
132
+
<LineChart data={clicksOverTime}>
133
+
<CartesianGrid strokeDasharray="3 3" />
134
+
<XAxis dataKey="date" />
135
+
<YAxis />
136
+
<Tooltip content={<CustomTooltip />} />
137
+
<Line
138
+
type="monotone"
139
+
dataKey="clicks"
140
+
stroke="#8884d8"
141
+
strokeWidth={2}
142
+
/>
143
+
</LineChart>
144
+
</ResponsiveContainer>
145
+
</div>
146
+
</CardContent>
147
+
</Card>
148
+
149
+
<Card>
150
+
<CardHeader>
151
+
<CardTitle>Top Sources</CardTitle>
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"
159
+
>
160
+
<span className="text-sm">
161
+
<span className="font-medium text-muted-foreground mr-2">
162
+
{index + 1}.
163
+
</span>
164
+
{source.source}
165
+
</span>
166
+
<span className="text-sm font-medium">{source.count} clicks</span>
167
+
</li>
168
+
))}
169
+
</ul>
170
+
</CardContent>
171
+
</Card>
172
+
</div>
173
+
)}
174
+
</DialogContent>
175
+
</Dialog>
176
+
);
115
177
}
+1
frontend/src/types/api.ts
+1
frontend/src/types/api.ts
+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
+
+168
-43
src/handlers.rs
+168
-43
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) => {
···
537
674
) -> Result<impl Responder, AppError> {
538
675
let link_id = path.into_inner();
539
676
540
-
// Verify the link belongs to the user
677
+
// First verify the link belongs to the user
541
678
let link = match &state.db {
542
679
DatabasePool::Postgres(pool) => {
543
-
let mut tx = pool.begin().await?;
544
-
let link = sqlx::query_as::<Postgres, (i32,)>(
545
-
"SELECT id FROM links WHERE id = $1 AND user_id = $2",
546
-
)
547
-
.bind(link_id)
548
-
.bind(user.user_id)
549
-
.fetch_optional(&mut *tx)
550
-
.await?;
551
-
tx.commit().await?;
552
-
link
680
+
sqlx::query_as::<_, (i32,)>("SELECT id FROM links WHERE id = $1 AND user_id = $2")
681
+
.bind(link_id)
682
+
.bind(user.user_id)
683
+
.fetch_optional(pool)
684
+
.await?
553
685
}
554
686
DatabasePool::Sqlite(pool) => {
555
-
let mut tx = pool.begin().await?;
556
-
let link = sqlx::query_as::<Sqlite, (i32,)>(
557
-
"SELECT id FROM links WHERE id = ? AND user_id = ?",
558
-
)
559
-
.bind(link_id)
560
-
.bind(user.user_id)
561
-
.fetch_optional(&mut *tx)
562
-
.await?;
563
-
tx.commit().await?;
564
-
link
687
+
sqlx::query_as::<_, (i32,)>("SELECT id FROM links WHERE id = ? AND user_id = ?")
688
+
.bind(link_id)
689
+
.bind(user.user_id)
690
+
.fetch_optional(pool)
691
+
.await?
565
692
}
566
693
};
567
694
···
571
698
572
699
let clicks = match &state.db {
573
700
DatabasePool::Postgres(pool) => {
574
-
sqlx::query_as::<Postgres, ClickStats>(
701
+
sqlx::query_as::<_, ClickStats>(
575
702
r#"
576
703
SELECT
577
-
DATE(created_at)::date as "date!",
578
-
COUNT(*)::bigint as "clicks!"
704
+
DATE(created_at)::text as date,
705
+
COUNT(*)::bigint as clicks
579
706
FROM clicks
580
707
WHERE link_id = $1
581
708
GROUP BY DATE(created_at)
582
709
ORDER BY DATE(created_at) ASC
583
-
LIMIT 30
584
710
"#,
585
711
)
586
712
.bind(link_id)
···
588
714
.await?
589
715
}
590
716
DatabasePool::Sqlite(pool) => {
591
-
sqlx::query_as::<Sqlite, ClickStats>(
717
+
sqlx::query_as::<_, ClickStats>(
592
718
r#"
593
719
SELECT
594
-
DATE(created_at) as "date!",
595
-
COUNT(*) as "clicks!"
720
+
DATE(created_at) as date,
721
+
COUNT(*) as clicks
596
722
FROM clicks
597
723
WHERE link_id = ?
598
724
GROUP BY DATE(created_at)
599
725
ORDER BY DATE(created_at) ASC
600
-
LIMIT 30
601
726
"#,
602
727
)
603
728
.bind(link_id)
···
650
775
651
776
let sources = match &state.db {
652
777
DatabasePool::Postgres(pool) => {
653
-
sqlx::query_as::<Postgres, SourceStats>(
778
+
sqlx::query_as::<_, SourceStats>(
654
779
r#"
655
780
SELECT
656
-
query_source as "source!",
657
-
COUNT(*)::bigint as "count!"
781
+
DATE(created_at)::text as date,
782
+
query_source as source,
783
+
COUNT(*)::bigint as count
658
784
FROM clicks
659
785
WHERE link_id = $1
660
786
AND query_source IS NOT NULL
661
787
AND query_source != ''
662
-
GROUP BY query_source
663
-
ORDER BY COUNT(*) DESC
664
-
LIMIT 10
788
+
GROUP BY DATE(created_at), query_source
789
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
665
790
"#,
666
791
)
667
792
.bind(link_id)
···
669
794
.await?
670
795
}
671
796
DatabasePool::Sqlite(pool) => {
672
-
sqlx::query_as::<Sqlite, SourceStats>(
797
+
sqlx::query_as::<_, SourceStats>(
673
798
r#"
674
799
SELECT
675
-
query_source as "source!",
676
-
COUNT(*) as "count!"
800
+
DATE(created_at) as date,
801
+
query_source as source,
802
+
COUNT(*) as count
677
803
FROM clicks
678
804
WHERE link_id = ?
679
805
AND query_source IS NOT NULL
680
806
AND query_source != ''
681
-
GROUP BY query_source
682
-
ORDER BY COUNT(*) DESC
683
-
LIMIT 10
807
+
GROUP BY DATE(created_at), query_source
808
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
684
809
"#,
685
810
)
686
811
.bind(link_id)
+11
-5
src/lib.rs
+11
-5
src/lib.rs
···
24
24
let database_url = std::env::var("DATABASE_URL").ok();
25
25
26
26
match database_url {
27
-
Some(url) if url.starts_with("postgres://") => {
27
+
Some(url) if url.starts_with("postgres://") || url.starts_with("postgresql://") => {
28
28
info!("Using PostgreSQL database");
29
29
let pool = PgPoolOptions::new()
30
30
.max_connections(5)
···
37
37
_ => {
38
38
info!("No PostgreSQL connection string found, using SQLite");
39
39
40
+
// Get the project root directory
41
+
let project_root = std::env::current_dir()?;
42
+
let data_dir = project_root.join("data");
43
+
40
44
// Create a data directory if it doesn't exist
41
-
let data_dir = std::path::Path::new("data");
42
45
if !data_dir.exists() {
43
-
std::fs::create_dir_all(data_dir)?;
46
+
std::fs::create_dir_all(&data_dir)?;
44
47
}
45
48
46
49
let db_path = data_dir.join("simplelink.db");
···
102
105
};
103
106
104
107
if user_count == 0 {
105
-
// Generate a random token using simple characters
106
108
let token: String = (0..32)
107
109
.map(|_| {
108
110
let idx = rand::thread_rng().gen_range(0..62);
···
114
116
})
115
117
.collect();
116
118
119
+
// Get the project root directory
120
+
let project_root = std::env::current_dir()?;
121
+
let token_path = project_root.join("admin-setup-token.txt");
122
+
117
123
// Save token to file
118
-
let mut file = File::create("admin-setup-token.txt")?;
124
+
let mut file = File::create(token_path)?;
119
125
writeln!(file, "{}", token)?;
120
126
121
127
info!("No users found - generated admin setup token");
+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(
+3
-3
src/models.rs
+3
-3
src/models.rs
···
1
1
use anyhow::Result;
2
-
use chrono::NaiveDate;
3
2
use futures::future::BoxFuture;
4
3
use serde::{Deserialize, Serialize};
5
4
use sqlx::postgres::PgRow;
···
88
87
.duration_since(UNIX_EPOCH)
89
88
.unwrap()
90
89
.as_secs() as usize
91
-
+ 24 * 60 * 60; // 24 hours from now
90
+
+ 14 * 24 * 60 * 60; // 2 weeks from now
92
91
93
92
Self { sub: user_id, exp }
94
93
}
···
145
144
146
145
#[derive(sqlx::FromRow, Serialize)]
147
146
pub struct ClickStats {
148
-
pub date: NaiveDate,
147
+
pub date: String,
149
148
pub clicks: i64,
150
149
}
151
150
152
151
#[derive(sqlx::FromRow, Serialize)]
153
152
pub struct SourceStats {
153
+
pub date: String,
154
154
pub source: String,
155
155
pub count: i64,
156
156
}