A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.

Compare changes

Choose any two refs to compare.

+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 ![MainView](readme_img/mainview.jpg) 5 6 6 7 ![StatsView](readme_img/statview.jpg) 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
··· 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
··· 19 19 - shortener-network 20 20 21 21 app: 22 - build: 23 - context: . 24 - dockerfile: Dockerfile 25 - args: 26 - - API_URL=${API_URL:-http://localhost:3000} 22 + image: ghcr.io/waveringana/simplelink:v2.2 27 23 container_name: shortener-app 28 24 ports: 29 25 - "8080:8080"
+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
··· 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
··· 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
··· 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
··· 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
··· 32 32 } 33 33 34 34 export interface SourceStats { 35 + date: string; 35 36 source: string; 36 37 count: number; 37 38 }
+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
··· 1 + -- PostgreSQL migration 2 + ALTER TABLE links ALTER COLUMN short_code TYPE VARCHAR(32); 3 +
+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
··· 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
··· 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
··· 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
··· 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 }