+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
-159
Cargo.lock
+9
-159
Cargo.lock
···
607
607
]
608
608
609
609
[[package]]
610
-
name = "core-foundation"
611
-
version = "0.9.4"
612
-
source = "registry+https://github.com/rust-lang/crates.io-index"
613
-
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
614
-
dependencies = [
615
-
"core-foundation-sys",
616
-
"libc",
617
-
]
618
-
619
-
[[package]]
620
610
name = "core-foundation-sys"
621
611
version = "0.8.7"
622
612
source = "registry+https://github.com/rust-lang/crates.io-index"
···
842
832
version = "0.1.4"
843
833
source = "registry+https://github.com/rust-lang/crates.io-index"
844
834
checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
845
-
846
-
[[package]]
847
-
name = "foreign-types"
848
-
version = "0.3.2"
849
-
source = "registry+https://github.com/rust-lang/crates.io-index"
850
-
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
851
-
dependencies = [
852
-
"foreign-types-shared",
853
-
]
854
-
855
-
[[package]]
856
-
name = "foreign-types-shared"
857
-
version = "0.1.1"
858
-
source = "registry+https://github.com/rust-lang/crates.io-index"
859
-
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
860
835
861
836
[[package]]
862
837
name = "form_urlencoded"
···
1464
1439
]
1465
1440
1466
1441
[[package]]
1467
-
name = "native-tls"
1468
-
version = "0.2.12"
1469
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1470
-
checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
1471
-
dependencies = [
1472
-
"libc",
1473
-
"log",
1474
-
"openssl",
1475
-
"openssl-probe",
1476
-
"openssl-sys",
1477
-
"schannel",
1478
-
"security-framework",
1479
-
"security-framework-sys",
1480
-
"tempfile",
1481
-
]
1482
-
1483
-
[[package]]
1484
1442
name = "nu-ansi-term"
1485
-
version = "0.46.0"
1443
+
version = "0.50.1"
1486
1444
source = "registry+https://github.com/rust-lang/crates.io-index"
1487
-
checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
1445
+
checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399"
1488
1446
dependencies = [
1489
-
"overload",
1490
-
"winapi",
1447
+
"windows-sys 0.52.0",
1491
1448
]
1492
1449
1493
1450
[[package]]
···
1569
1526
checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
1570
1527
1571
1528
[[package]]
1572
-
name = "openssl"
1573
-
version = "0.10.68"
1574
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1575
-
checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5"
1576
-
dependencies = [
1577
-
"bitflags",
1578
-
"cfg-if",
1579
-
"foreign-types",
1580
-
"libc",
1581
-
"once_cell",
1582
-
"openssl-macros",
1583
-
"openssl-sys",
1584
-
]
1585
-
1586
-
[[package]]
1587
-
name = "openssl-macros"
1588
-
version = "0.1.1"
1589
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1590
-
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
1591
-
dependencies = [
1592
-
"proc-macro2",
1593
-
"quote",
1594
-
"syn",
1595
-
]
1596
-
1597
-
[[package]]
1598
-
name = "openssl-probe"
1599
-
version = "0.1.6"
1600
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1601
-
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
1602
-
1603
-
[[package]]
1604
-
name = "openssl-sys"
1605
-
version = "0.9.104"
1606
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1607
-
checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741"
1608
-
dependencies = [
1609
-
"cc",
1610
-
"libc",
1611
-
"pkg-config",
1612
-
"vcpkg",
1613
-
]
1614
-
1615
-
[[package]]
1616
-
name = "overload"
1617
-
version = "0.1.1"
1618
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1619
-
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
1620
-
1621
-
[[package]]
1622
1529
name = "parking"
1623
1530
version = "2.2.1"
1624
1531
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1837
1744
1838
1745
[[package]]
1839
1746
name = "ring"
1840
-
version = "0.17.8"
1747
+
version = "0.17.13"
1841
1748
source = "registry+https://github.com/rust-lang/crates.io-index"
1842
-
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
1749
+
checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee"
1843
1750
dependencies = [
1844
1751
"cc",
1845
1752
"cfg-if",
1846
1753
"getrandom",
1847
1754
"libc",
1848
-
"spin",
1849
1755
"untrusted",
1850
1756
"windows-sys 0.52.0",
1851
1757
]
···
1954
1860
]
1955
1861
1956
1862
[[package]]
1957
-
name = "schannel"
1958
-
version = "0.1.27"
1959
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1960
-
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
1961
-
dependencies = [
1962
-
"windows-sys 0.59.0",
1963
-
]
1964
-
1965
-
[[package]]
1966
1863
name = "scopeguard"
1967
1864
version = "1.2.0"
1968
1865
source = "registry+https://github.com/rust-lang/crates.io-index"
1969
1866
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1970
1867
1971
1868
[[package]]
1972
-
name = "security-framework"
1973
-
version = "2.11.1"
1974
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1975
-
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
1976
-
dependencies = [
1977
-
"bitflags",
1978
-
"core-foundation",
1979
-
"core-foundation-sys",
1980
-
"libc",
1981
-
"security-framework-sys",
1982
-
]
1983
-
1984
-
[[package]]
1985
-
name = "security-framework-sys"
1986
-
version = "2.14.0"
1987
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1988
-
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
1989
-
dependencies = [
1990
-
"core-foundation-sys",
1991
-
"libc",
1992
-
]
1993
-
1994
-
[[package]]
1995
1869
name = "semver"
1996
1870
version = "1.0.25"
1997
1871
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2220
2094
"indexmap",
2221
2095
"log",
2222
2096
"memchr",
2223
-
"native-tls",
2224
2097
"once_cell",
2225
2098
"percent-encoding",
2226
2099
"serde",
···
2551
2424
2552
2425
[[package]]
2553
2426
name = "tokio"
2554
-
version = "1.43.0"
2427
+
version = "1.43.1"
2555
2428
source = "registry+https://github.com/rust-lang/crates.io-index"
2556
-
checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e"
2429
+
checksum = "492a604e2fd7f814268a378409e6c92b5525d747d10db9a229723f55a417958c"
2557
2430
dependencies = [
2558
2431
"backtrace",
2559
2432
"bytes",
···
2648
2521
2649
2522
[[package]]
2650
2523
name = "tracing-subscriber"
2651
-
version = "0.3.19"
2524
+
version = "0.3.20"
2652
2525
source = "registry+https://github.com/rust-lang/crates.io-index"
2653
-
checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
2526
+
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
2654
2527
dependencies = [
2655
2528
"nu-ansi-term",
2656
2529
"sharded-slab",
···
2741
2614
checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b"
2742
2615
dependencies = [
2743
2616
"getrandom",
2744
-
"serde",
2745
2617
]
2746
2618
2747
2619
[[package]]
···
2859
2731
]
2860
2732
2861
2733
[[package]]
2862
-
name = "winapi"
2863
-
version = "0.3.9"
2864
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2865
-
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
2866
-
dependencies = [
2867
-
"winapi-i686-pc-windows-gnu",
2868
-
"winapi-x86_64-pc-windows-gnu",
2869
-
]
2870
-
2871
-
[[package]]
2872
-
name = "winapi-i686-pc-windows-gnu"
2873
-
version = "0.4.0"
2874
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2875
-
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
2876
-
2877
-
[[package]]
2878
2734
name = "winapi-util"
2879
2735
version = "0.1.9"
2880
2736
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2882
2738
dependencies = [
2883
2739
"windows-sys 0.59.0",
2884
2740
]
2885
-
2886
-
[[package]]
2887
-
name = "winapi-x86_64-pc-windows-gnu"
2888
-
version = "0.4.0"
2889
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2890
-
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
2891
2741
2892
2742
[[package]]
2893
2743
name = "windows-core"
+3
-3
Cargo.toml
+3
-3
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 = ["full"] }
17
-
sqlx = { version = "0.8", features = ["runtime-tokio-native-tls", "postgres", "sqlite", "chrono"] }
16
+
tokio = { version = "1.43", features = ["rt-multi-thread", "macros"] }
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"
20
20
anyhow = "1.0"
21
21
thiserror = "1.0"
22
22
tracing = "0.1"
23
23
tracing-subscriber = "0.3"
24
-
uuid = { version = "1.7", features = ["v4", "serde"] }
24
+
uuid = { version = "1.7", features = ["v4"] } # Remove serde if not using UUID serialization
25
25
base62 = "2.0"
26
26
clap = { version = "4.5", features = ["derive"] }
27
27
dotenv = "0.15"
+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
-
If DATABASE_URL is set, it will connect to a Postgres DB. If blank, it will use an sqlite db in /data
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
+26
-4
frontend/src/api/client.ts
+26
-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
+
}
89
+
};
90
+
91
+
92
+
export const checkFirstUser = async () => {
93
+
const response = await api.get<{ isFirstUser: boolean }>('/auth/check-first-user');
94
+
return response.data.isFirstUser;
73
95
};
74
96
75
97
export { api };
+82
-62
frontend/src/components/AuthForms.tsx
+82
-62
frontend/src/components/AuthForms.tsx
···
1
-
import { useState } from 'react'
1
+
import { useState, useEffect } from 'react'
2
2
import { useForm } from 'react-hook-form'
3
3
import { z } from 'zod'
4
4
import { zodResolver } from '@hookform/resolvers/zod'
···
6
6
import { Button } from '@/components/ui/button'
7
7
import { Input } from '@/components/ui/input'
8
8
import { Card } from '@/components/ui/card'
9
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
10
9
import {
11
10
Form,
12
11
FormControl,
···
16
15
FormMessage,
17
16
} from '@/components/ui/form'
18
17
import { useToast } from '@/hooks/use-toast'
18
+
import { checkFirstUser } from '../api/client'
19
19
20
20
const formSchema = z.object({
21
21
email: z.string().email('Invalid email address'),
22
22
password: z.string().min(6, 'Password must be at least 6 characters long'),
23
-
adminToken: z.string(),
23
+
adminToken: z.string().optional(),
24
24
})
25
25
26
26
type FormValues = z.infer<typeof formSchema>
27
27
28
28
export function AuthForms() {
29
-
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login')
29
+
const [isFirstUser, setIsFirstUser] = useState<boolean | null>(null)
30
30
const { login, register } = useAuth()
31
31
const { toast } = useToast()
32
32
···
39
39
},
40
40
})
41
41
42
+
useEffect(() => {
43
+
const init = async () => {
44
+
try {
45
+
const isFirst = await checkFirstUser()
46
+
setIsFirstUser(isFirst)
47
+
} catch (err) {
48
+
console.error('Error checking first user:', err)
49
+
setIsFirstUser(false)
50
+
}
51
+
}
52
+
53
+
init()
54
+
}, [])
55
+
42
56
const onSubmit = async (values: FormValues) => {
43
57
try {
44
-
if (activeTab === 'login') {
45
-
await login(values.email, values.password)
58
+
if (isFirstUser) {
59
+
await register(values.email, values.password, values.adminToken || '')
46
60
} else {
47
-
await register(values.email, values.password, values.adminToken)
61
+
await login(values.email, values.password)
48
62
}
49
63
form.reset()
50
64
} catch (err: any) {
···
56
70
}
57
71
}
58
72
73
+
if (isFirstUser === null) {
74
+
return <div>Loading...</div>
75
+
}
76
+
59
77
return (
60
78
<Card className="w-full max-w-md mx-auto p-6">
61
-
<Tabs value={activeTab} onValueChange={(value: string) => setActiveTab(value as 'login' | 'register')}>
62
-
<TabsList className="grid w-full grid-cols-2">
63
-
<TabsTrigger value="login">Login</TabsTrigger>
64
-
<TabsTrigger value="register">Register</TabsTrigger>
65
-
</TabsList>
79
+
<div className="mb-6 text-center">
80
+
<h2 className="text-2xl font-bold">
81
+
{isFirstUser ? 'Create Admin Account' : 'Login'}
82
+
</h2>
83
+
<p className="text-sm text-muted-foreground mt-1">
84
+
{isFirstUser
85
+
? 'Set up your admin account to get started'
86
+
: 'Welcome back! Please login to your account'}
87
+
</p>
88
+
</div>
66
89
67
-
<TabsContent value={activeTab}>
68
-
<Form {...form}>
69
-
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
70
-
<FormField
71
-
control={form.control}
72
-
name="email"
73
-
render={({ field }) => (
74
-
<FormItem>
75
-
<FormLabel>Email</FormLabel>
76
-
<FormControl>
77
-
<Input type="email" {...field} />
78
-
</FormControl>
79
-
<FormMessage />
80
-
</FormItem>
81
-
)}
82
-
/>
90
+
<Form {...form}>
91
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
92
+
<FormField
93
+
control={form.control}
94
+
name="email"
95
+
render={({ field }) => (
96
+
<FormItem>
97
+
<FormLabel>Email</FormLabel>
98
+
<FormControl>
99
+
<Input type="email" {...field} />
100
+
</FormControl>
101
+
<FormMessage />
102
+
</FormItem>
103
+
)}
104
+
/>
83
105
84
-
<FormField
85
-
control={form.control}
86
-
name="password"
87
-
render={({ field }) => (
88
-
<FormItem>
89
-
<FormLabel>Password</FormLabel>
90
-
<FormControl>
91
-
<Input type="password" {...field} />
92
-
</FormControl>
93
-
<FormMessage />
94
-
</FormItem>
95
-
)}
96
-
/>
106
+
<FormField
107
+
control={form.control}
108
+
name="password"
109
+
render={({ field }) => (
110
+
<FormItem>
111
+
<FormLabel>Password</FormLabel>
112
+
<FormControl>
113
+
<Input type="password" {...field} />
114
+
</FormControl>
115
+
<FormMessage />
116
+
</FormItem>
117
+
)}
118
+
/>
97
119
98
-
{activeTab === 'register' && (
99
-
<FormField
100
-
control={form.control}
101
-
name="adminToken"
102
-
render={({ field }) => (
103
-
<FormItem>
104
-
<FormLabel>Admin Setup Token</FormLabel>
105
-
<FormControl>
106
-
<Input type="text" {...field} />
107
-
</FormControl>
108
-
<FormMessage />
109
-
</FormItem>
110
-
)}
111
-
/>
120
+
{isFirstUser && (
121
+
<FormField
122
+
control={form.control}
123
+
name="adminToken"
124
+
render={({ field }) => (
125
+
<FormItem>
126
+
<FormLabel>Admin Setup Token</FormLabel>
127
+
<FormControl>
128
+
<Input type="text" {...field} />
129
+
</FormControl>
130
+
<FormMessage />
131
+
</FormItem>
112
132
)}
133
+
/>
134
+
)}
113
135
114
-
<Button type="submit" className="w-full">
115
-
{activeTab === 'login' ? 'Sign in' : 'Create account'}
116
-
</Button>
117
-
</form>
118
-
</Form>
119
-
</TabsContent>
120
-
</Tabs>
136
+
<Button type="submit" className="w-full">
137
+
{isFirstUser ? 'Create Account' : 'Sign in'}
138
+
</Button>
139
+
</form>
140
+
</Form>
121
141
</Card>
122
142
)
123
143
}
+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
+
+190
-43
src/handlers.rs
+190
-43
src/handlers.rs
···
16
16
use jsonwebtoken::{encode, EncodingKey, Header};
17
17
use lazy_static::lazy_static;
18
18
use regex::Regex;
19
+
use serde_json::json;
19
20
use sqlx::{Postgres, Sqlite};
20
21
21
22
lazy_static! {
···
130
131
Ok(())
131
132
}
132
133
133
-
fn validate_url(url: &String) -> Result<(), AppError> {
134
+
fn validate_url(url: &str) -> Result<(), AppError> {
134
135
if url.is_empty() {
135
136
return Err(AppError::InvalidInput("URL cannot be empty".to_string()));
136
137
}
···
456
457
}))
457
458
}
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
+
459
597
pub async fn delete_link(
460
598
state: web::Data<AppState>,
461
599
user: AuthenticatedUser,
462
600
path: web::Path<i32>,
463
601
) -> Result<impl Responder, AppError> {
464
-
let link_id = path.into_inner();
602
+
let link_id: i32 = path.into_inner();
465
603
466
604
match &state.db {
467
605
DatabasePool::Postgres(pool) => {
···
536
674
) -> Result<impl Responder, AppError> {
537
675
let link_id = path.into_inner();
538
676
539
-
// Verify the link belongs to the user
677
+
// First verify the link belongs to the user
540
678
let link = match &state.db {
541
679
DatabasePool::Postgres(pool) => {
542
-
let mut tx = pool.begin().await?;
543
-
let link = sqlx::query_as::<Postgres, (i32,)>(
544
-
"SELECT id FROM links WHERE id = $1 AND user_id = $2",
545
-
)
546
-
.bind(link_id)
547
-
.bind(user.user_id)
548
-
.fetch_optional(&mut *tx)
549
-
.await?;
550
-
tx.commit().await?;
551
-
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?
552
685
}
553
686
DatabasePool::Sqlite(pool) => {
554
-
let mut tx = pool.begin().await?;
555
-
let link = sqlx::query_as::<Sqlite, (i32,)>(
556
-
"SELECT id FROM links WHERE id = ? AND user_id = ?",
557
-
)
558
-
.bind(link_id)
559
-
.bind(user.user_id)
560
-
.fetch_optional(&mut *tx)
561
-
.await?;
562
-
tx.commit().await?;
563
-
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?
564
692
}
565
693
};
566
694
···
570
698
571
699
let clicks = match &state.db {
572
700
DatabasePool::Postgres(pool) => {
573
-
sqlx::query_as::<Postgres, ClickStats>(
701
+
sqlx::query_as::<_, ClickStats>(
574
702
r#"
575
703
SELECT
576
-
DATE(created_at)::date as "date!",
577
-
COUNT(*)::bigint as "clicks!"
704
+
DATE(created_at)::text as date,
705
+
COUNT(*)::bigint as clicks
578
706
FROM clicks
579
707
WHERE link_id = $1
580
708
GROUP BY DATE(created_at)
581
709
ORDER BY DATE(created_at) ASC
582
-
LIMIT 30
583
710
"#,
584
711
)
585
712
.bind(link_id)
···
587
714
.await?
588
715
}
589
716
DatabasePool::Sqlite(pool) => {
590
-
sqlx::query_as::<Sqlite, ClickStats>(
717
+
sqlx::query_as::<_, ClickStats>(
591
718
r#"
592
719
SELECT
593
-
DATE(created_at) as "date!",
594
-
COUNT(*) as "clicks!"
720
+
DATE(created_at) as date,
721
+
COUNT(*) as clicks
595
722
FROM clicks
596
723
WHERE link_id = ?
597
724
GROUP BY DATE(created_at)
598
725
ORDER BY DATE(created_at) ASC
599
-
LIMIT 30
600
726
"#,
601
727
)
602
728
.bind(link_id)
···
649
775
650
776
let sources = match &state.db {
651
777
DatabasePool::Postgres(pool) => {
652
-
sqlx::query_as::<Postgres, SourceStats>(
778
+
sqlx::query_as::<_, SourceStats>(
653
779
r#"
654
780
SELECT
655
-
query_source as "source!",
656
-
COUNT(*)::bigint as "count!"
781
+
DATE(created_at)::text as date,
782
+
query_source as source,
783
+
COUNT(*)::bigint as count
657
784
FROM clicks
658
785
WHERE link_id = $1
659
786
AND query_source IS NOT NULL
660
787
AND query_source != ''
661
-
GROUP BY query_source
662
-
ORDER BY COUNT(*) DESC
663
-
LIMIT 10
788
+
GROUP BY DATE(created_at), query_source
789
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
664
790
"#,
665
791
)
666
792
.bind(link_id)
···
668
794
.await?
669
795
}
670
796
DatabasePool::Sqlite(pool) => {
671
-
sqlx::query_as::<Sqlite, SourceStats>(
797
+
sqlx::query_as::<_, SourceStats>(
672
798
r#"
673
799
SELECT
674
-
query_source as "source!",
675
-
COUNT(*) as "count!"
800
+
DATE(created_at) as date,
801
+
query_source as source,
802
+
COUNT(*) as count
676
803
FROM clicks
677
804
WHERE link_id = ?
678
805
AND query_source IS NOT NULL
679
806
AND query_source != ''
680
-
GROUP BY query_source
681
-
ORDER BY COUNT(*) DESC
682
-
LIMIT 10
807
+
GROUP BY DATE(created_at), query_source
808
+
ORDER BY DATE(created_at) ASC, COUNT(*) DESC
683
809
"#,
684
810
)
685
811
.bind(link_id)
···
690
816
691
817
Ok(HttpResponse::Ok().json(sources))
692
818
}
819
+
820
+
pub async fn check_first_user(state: web::Data<AppState>) -> Result<impl Responder, AppError> {
821
+
let user_count = match &state.db {
822
+
DatabasePool::Postgres(pool) => {
823
+
sqlx::query_as::<Postgres, (i64,)>("SELECT COUNT(*)::bigint FROM users")
824
+
.fetch_one(pool)
825
+
.await?
826
+
.0
827
+
}
828
+
DatabasePool::Sqlite(pool) => {
829
+
sqlx::query_as::<Sqlite, (i64,)>("SELECT COUNT(*) FROM users")
830
+
.fetch_one(pool)
831
+
.await?
832
+
.0
833
+
}
834
+
};
835
+
836
+
Ok(HttpResponse::Ok().json(json!({
837
+
"isFirstUser": user_count == 0
838
+
})))
839
+
}
+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");
+163
-1
src/main.rs
+163
-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))
233
+
.route(
234
+
"/auth/check-first-user",
235
+
web::get().to(handlers::check_first_user),
236
+
)
75
237
.route("/health", web::get().to(handlers::health_check)),
76
238
)
77
239
.service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url)))
+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
}