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

readme

+1
.env.example
··· 1 1 DATABASE_URL=postgresql://user:password@localhost/dbname 2 2 SERVER_HOST=127.0.0.1 3 3 SERVER_PORT=8080 4 + JWT_SECRET=change-me-in-production
+64
Cargo.lock
··· 1858 1858 ] 1859 1859 1860 1860 [[package]] 1861 + name = "rust-embed" 1862 + version = "6.8.1" 1863 + source = "registry+https://github.com/rust-lang/crates.io-index" 1864 + checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661" 1865 + dependencies = [ 1866 + "rust-embed-impl", 1867 + "rust-embed-utils", 1868 + "walkdir", 1869 + ] 1870 + 1871 + [[package]] 1872 + name = "rust-embed-impl" 1873 + version = "6.8.1" 1874 + source = "registry+https://github.com/rust-lang/crates.io-index" 1875 + checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac" 1876 + dependencies = [ 1877 + "proc-macro2", 1878 + "quote", 1879 + "rust-embed-utils", 1880 + "syn", 1881 + "walkdir", 1882 + ] 1883 + 1884 + [[package]] 1885 + name = "rust-embed-utils" 1886 + version = "7.8.1" 1887 + source = "registry+https://github.com/rust-lang/crates.io-index" 1888 + checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74" 1889 + dependencies = [ 1890 + "sha2", 1891 + "walkdir", 1892 + ] 1893 + 1894 + [[package]] 1861 1895 name = "rustc-demangle" 1862 1896 version = "0.1.24" 1863 1897 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1896 1930 version = "1.0.18" 1897 1931 source = "registry+https://github.com/rust-lang/crates.io-index" 1898 1932 checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 1933 + 1934 + [[package]] 1935 + name = "same-file" 1936 + version = "1.0.6" 1937 + source = "registry+https://github.com/rust-lang/crates.io-index" 1938 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 1939 + dependencies = [ 1940 + "winapi-util", 1941 + ] 1899 1942 1900 1943 [[package]] 1901 1944 name = "schannel" ··· 2068 2111 "dotenv", 2069 2112 "jsonwebtoken", 2070 2113 "lazy_static", 2114 + "mime_guess", 2071 2115 "rand", 2072 2116 "regex", 2117 + "rust-embed", 2073 2118 "serde", 2074 2119 "serde_json", 2075 2120 "sqlx", ··· 2737 2782 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 2738 2783 2739 2784 [[package]] 2785 + name = "walkdir" 2786 + version = "2.5.0" 2787 + source = "registry+https://github.com/rust-lang/crates.io-index" 2788 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 2789 + dependencies = [ 2790 + "same-file", 2791 + "winapi-util", 2792 + ] 2793 + 2794 + [[package]] 2740 2795 name = "wasi" 2741 2796 version = "0.11.0+wasi-snapshot-preview1" 2742 2797 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2831 2886 version = "0.4.0" 2832 2887 source = "registry+https://github.com/rust-lang/crates.io-index" 2833 2888 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 2889 + 2890 + [[package]] 2891 + name = "winapi-util" 2892 + version = "0.1.9" 2893 + source = "registry+https://github.com/rust-lang/crates.io-index" 2894 + checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 2895 + dependencies = [ 2896 + "windows-sys 0.59.0", 2897 + ] 2834 2898 2835 2899 [[package]] 2836 2900 name = "winapi-x86_64-pc-windows-gnu"
+3 -1
Cargo.toml
··· 8 8 path = "src/lib.rs" 9 9 10 10 [dependencies] 11 + rust-embed = "6.8" 11 12 jsonwebtoken = "9" 12 13 actix-web = "4.4" 13 14 actix-files = "0.6" ··· 28 29 regex = "1.10" 29 30 lazy_static = "1.4" 30 31 argon2 = "0.5.3" 31 - rand = { version = "0.8", features = ["std"] } 32 + rand = { version = "0.8", features = ["std"] } 33 + mime_guess = "2.0.5"
+38
README.md
··· 1 + # SimpleLink 2 + A very performant and light (6mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres. 3 + 4 + ![MainView](readme_img/mainview.jpg) 5 + 6 + ![StatsView](readme_img/statview.jpg) 7 + 8 + ## Build 9 + 10 + ### From Source 11 + First configure .env.example and save it to .env 12 + 13 + The project will not run withot DATABASE_URL set. (TODO add sqlite support) 14 + 15 + ```bash 16 + #set api-domain to where you will be deploying the link shortener, eg: link.example.com, default is localhost:8080 17 + git clone https://github.com/waveringana/simplelink && cd simplelink 18 + ./build.sh api-domain=localhost:8080 19 + cargo run 20 + ``` 21 + 22 + Alternatively if you want a binary form 23 + ```bash 24 + ./build.sh --binary 25 + ``` 26 + then check /target/release for the binary named `SimpleGit` 27 + 28 + ### From Docker 29 + ```bash 30 + docker build --build-arg API_URL=http://localhost:8080 -t simplelink . 31 + docker run simplelink -p 8080:8080 \ 32 + -e JWT_SECRET=change-me-in-production \ 33 + -e DATABASE_URL=postgres://user:password@host:port/database \ 34 + simplelink 35 + ``` 36 + 37 + ### From Docker Compose 38 + Adjust the included docker-compose.yml to your liking, it includes a postgres config as well.
+9 -7
build.sh
··· 3 3 # Default values 4 4 API_URL="http://localhost:8080" 5 5 RELEASE_MODE=false 6 + BINARY_MODE=false 6 7 7 8 # Parse command line arguments 8 9 for arg in "$@" ··· 14 15 ;; 15 16 --release) 16 17 RELEASE_MODE=true 18 + shift 19 + ;; 20 + --binary) 21 + BINARY_MODE=true 17 22 shift 18 23 ;; 19 24 esac ··· 45 50 npm run build 46 51 cd .. 47 52 48 - # Create static directory if it doesn't exist 53 + # Create static directory and copy frontend build 49 54 mkdir -p static 50 - 51 - # Clean existing static files 52 55 rm -rf static/* 53 - 54 - # Copy built files to static directory 55 56 cp -r frontend/dist/* static/ 56 57 57 58 # Build Rust project ··· 62 63 # Create release directory 63 64 mkdir -p release 64 65 65 - # Copy binary and static files to release directory 66 + # Copy only the binary to release directory 66 67 cp target/release/simplelink release/ 67 - cp -r static release/ 68 68 cp .env.example release/.env 69 69 70 70 # Create a tar archive 71 71 tar -czf release.tar.gz release/ 72 72 73 73 echo "Release archive created: release.tar.gz" 74 + elif [ "$BINARY_MODE" = true ]; then 75 + cargo build --release 74 76 else 75 77 cargo build 76 78 fi
+3 -2
docker-compose.yml
··· 26 26 - API_URL=${API_URL:-http://localhost:3000} 27 27 container_name: shortener-app 28 28 ports: 29 - - "3000:3000" 29 + - "8080:8080" 30 30 environment: 31 31 - DATABASE_URL=postgresql://shortener:shortener123@db:5432/shortener 32 32 - SERVER_HOST=0.0.0.0 33 - - SERVER_PORT=3000 33 + - SERVER_PORT=8080 34 + - JWT_SECRET=change-me-in-production 34 35 depends_on: 35 36 db: 36 37 condition: service_healthy
-166
frontend/package-lock.json
··· 9 9 "version": "0.0.0", 10 10 "dependencies": { 11 11 "@emotion/react": "^11.14.0", 12 - "@headlessui/react": "^2.2.0", 13 12 "@hookform/resolvers": "^3.10.0", 14 13 "@icons-pack/react-simple-icons": "^11.2.0", 15 14 "@mantine/core": "^7.16.1", ··· 29 28 "react": "^18.3.1", 30 29 "react-dom": "^18.3.1", 31 30 "react-hook-form": "^7.54.2", 32 - "react-simple-icons": "^1.0.0-beta.5", 33 31 "recharts": "^2.15.0", 34 - "simple-icons": "^14.4.0", 35 32 "tailwind-merge": "^2.6.0", 36 33 "tailwindcss-animate": "^1.0.7", 37 34 "zod": "^3.24.1" ··· 595 592 "version": "0.2.9", 596 593 "license": "MIT" 597 594 }, 598 - "node_modules/@headlessui/react": { 599 - "version": "2.2.0", 600 - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz", 601 - "integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==", 602 - "license": "MIT", 603 - "dependencies": { 604 - "@floating-ui/react": "^0.26.16", 605 - "@react-aria/focus": "^3.17.1", 606 - "@react-aria/interactions": "^3.21.3", 607 - "@tanstack/react-virtual": "^3.8.1" 608 - }, 609 - "engines": { 610 - "node": ">=10" 611 - }, 612 - "peerDependencies": { 613 - "react": "^18 || ^19 || ^19.0.0-rc", 614 - "react-dom": "^18 || ^19 || ^19.0.0-rc" 615 - } 616 - }, 617 595 "node_modules/@hookform/resolvers": { 618 596 "version": "3.10.0", 619 597 "license": "MIT", ··· 721 699 "@jridgewell/resolve-uri": "^3.1.0", 722 700 "@jridgewell/sourcemap-codec": "^1.4.14" 723 701 } 724 - }, 725 - "node_modules/@jxnblk/simple-icons": { 726 - "version": "1.0.0", 727 - "license": "MIT" 728 702 }, 729 703 "node_modules/@mantine/core": { 730 704 "version": "7.16.1", ··· 1395 1369 "version": "1.1.0", 1396 1370 "license": "MIT" 1397 1371 }, 1398 - "node_modules/@react-aria/focus": { 1399 - "version": "3.19.1", 1400 - "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.19.1.tgz", 1401 - "integrity": "sha512-bix9Bu1Ue7RPcYmjwcjhB14BMu2qzfJ3tMQLqDc9pweJA66nOw8DThy3IfVr8Z7j2PHktOLf9kcbiZpydKHqzg==", 1402 - "license": "Apache-2.0", 1403 - "dependencies": { 1404 - "@react-aria/interactions": "^3.23.0", 1405 - "@react-aria/utils": "^3.27.0", 1406 - "@react-types/shared": "^3.27.0", 1407 - "@swc/helpers": "^0.5.0", 1408 - "clsx": "^2.0.0" 1409 - }, 1410 - "peerDependencies": { 1411 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", 1412 - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 1413 - } 1414 - }, 1415 - "node_modules/@react-aria/interactions": { 1416 - "version": "3.23.0", 1417 - "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.23.0.tgz", 1418 - "integrity": "sha512-0qR1atBIWrb7FzQ+Tmr3s8uH5mQdyRH78n0krYaG8tng9+u1JlSi8DGRSaC9ezKyNB84m7vHT207xnHXGeJ3Fg==", 1419 - "license": "Apache-2.0", 1420 - "dependencies": { 1421 - "@react-aria/ssr": "^3.9.7", 1422 - "@react-aria/utils": "^3.27.0", 1423 - "@react-types/shared": "^3.27.0", 1424 - "@swc/helpers": "^0.5.0" 1425 - }, 1426 - "peerDependencies": { 1427 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", 1428 - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 1429 - } 1430 - }, 1431 - "node_modules/@react-aria/ssr": { 1432 - "version": "3.9.7", 1433 - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz", 1434 - "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==", 1435 - "license": "Apache-2.0", 1436 - "dependencies": { 1437 - "@swc/helpers": "^0.5.0" 1438 - }, 1439 - "engines": { 1440 - "node": ">= 12" 1441 - }, 1442 - "peerDependencies": { 1443 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 1444 - } 1445 - }, 1446 - "node_modules/@react-aria/utils": { 1447 - "version": "3.27.0", 1448 - "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.27.0.tgz", 1449 - "integrity": "sha512-p681OtApnKOdbeN8ITfnnYqfdHS0z7GE+4l8EXlfLnr70Rp/9xicBO6d2rU+V/B3JujDw2gPWxYKEnEeh0CGCw==", 1450 - "license": "Apache-2.0", 1451 - "dependencies": { 1452 - "@react-aria/ssr": "^3.9.7", 1453 - "@react-stately/utils": "^3.10.5", 1454 - "@react-types/shared": "^3.27.0", 1455 - "@swc/helpers": "^0.5.0", 1456 - "clsx": "^2.0.0" 1457 - }, 1458 - "peerDependencies": { 1459 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", 1460 - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 1461 - } 1462 - }, 1463 - "node_modules/@react-stately/utils": { 1464 - "version": "3.10.5", 1465 - "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.5.tgz", 1466 - "integrity": "sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==", 1467 - "license": "Apache-2.0", 1468 - "dependencies": { 1469 - "@swc/helpers": "^0.5.0" 1470 - }, 1471 - "peerDependencies": { 1472 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 1473 - } 1474 - }, 1475 - "node_modules/@react-types/shared": { 1476 - "version": "3.27.0", 1477 - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.27.0.tgz", 1478 - "integrity": "sha512-gvznmLhi6JPEf0bsq7SwRYTHAKKq/wcmKqFez9sRdbED+SPMUmK5omfZ6w3EwUFQHbYUa4zPBYedQ7Knv70RMw==", 1479 - "license": "Apache-2.0", 1480 - "peerDependencies": { 1481 - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" 1482 - } 1483 - }, 1484 1372 "node_modules/@rollup/rollup-darwin-arm64": { 1485 1373 "version": "4.32.0", 1486 1374 "cpu": [ ··· 1491 1379 "os": [ 1492 1380 "darwin" 1493 1381 ] 1494 - }, 1495 - "node_modules/@swc/helpers": { 1496 - "version": "0.5.15", 1497 - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", 1498 - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", 1499 - "license": "Apache-2.0", 1500 - "dependencies": { 1501 - "tslib": "^2.8.0" 1502 - } 1503 1382 }, 1504 1383 "node_modules/@tailwindcss/node": { 1505 1384 "version": "4.0.0", ··· 1568 1447 }, 1569 1448 "peerDependencies": { 1570 1449 "vite": "^5.2.0 || ^6" 1571 - } 1572 - }, 1573 - "node_modules/@tanstack/react-virtual": { 1574 - "version": "3.11.3", 1575 - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.3.tgz", 1576 - "integrity": "sha512-vCU+OTylXN3hdC8RKg68tPlBPjjxtzon7Ys46MgrSLE+JhSjSTPvoQifV6DQJeJmA8Q3KT6CphJbejupx85vFw==", 1577 - "license": "MIT", 1578 - "dependencies": { 1579 - "@tanstack/virtual-core": "3.11.3" 1580 - }, 1581 - "funding": { 1582 - "type": "github", 1583 - "url": "https://github.com/sponsors/tannerlinsley" 1584 - }, 1585 - "peerDependencies": { 1586 - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 1587 - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 1588 - } 1589 - }, 1590 - "node_modules/@tanstack/virtual-core": { 1591 - "version": "3.11.3", 1592 - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.3.tgz", 1593 - "integrity": "sha512-v2mrNSnMwnPJtcVqNvV0c5roGCBqeogN8jDtgtuHCphdwBasOZ17x8UV8qpHUh+u0MLfX43c0uUHKje0s+Zb0w==", 1594 - "license": "MIT", 1595 - "funding": { 1596 - "type": "github", 1597 - "url": "https://github.com/sponsors/tannerlinsley" 1598 1450 } 1599 1451 }, 1600 1452 "node_modules/@types/babel__core": { ··· 3488 3340 } 3489 3341 } 3490 3342 }, 3491 - "node_modules/react-simple-icons": { 3492 - "version": "1.0.0-beta.5", 3493 - "license": "MIT", 3494 - "dependencies": { 3495 - "@jxnblk/simple-icons": "^1.0.0" 3496 - } 3497 - }, 3498 3343 "node_modules/react-smooth": { 3499 3344 "version": "4.0.4", 3500 3345 "license": "MIT", ··· 3713 3558 "license": "MIT", 3714 3559 "engines": { 3715 3560 "node": ">=8" 3716 - } 3717 - }, 3718 - "node_modules/simple-icons": { 3719 - "version": "14.4.0", 3720 - "license": "CC0-1.0", 3721 - "engines": { 3722 - "node": ">=0.12.18" 3723 - }, 3724 - "funding": { 3725 - "type": "opencollective", 3726 - "url": "https://opencollective.com/simple-icons" 3727 3561 } 3728 3562 }, 3729 3563 "node_modules/source-map": {
readme_img/mainview.jpg

This is a binary file and will not be displayed.

readme_img/statview.jpg

This is a binary file and will not be displayed.

+23 -3
src/main.rs
··· 1 1 use actix_cors::Cors; 2 - use actix_files as fs; 3 - use actix_web::{web, App, HttpServer}; 2 + use actix_web::{web, App, HttpResponse, HttpServer}; 4 3 use anyhow::Result; 4 + use rust_embed::RustEmbed; 5 5 use simplelink::check_and_generate_admin_token; 6 6 use simplelink::{handlers, AppState}; 7 7 use sqlx::postgres::PgPoolOptions; 8 8 use tracing::info; 9 + 10 + #[derive(RustEmbed)] 11 + #[folder = "static/"] 12 + struct Asset; 13 + 14 + async fn serve_static_file(path: &str) -> HttpResponse { 15 + match Asset::get(path) { 16 + Some(content) => { 17 + let mime = mime_guess::from_path(path).first_or_octet_stream(); 18 + HttpResponse::Ok() 19 + .content_type(mime.as_ref()) 20 + .body(content.data.into_owned()) 21 + } 22 + None => HttpResponse::NotFound().body("404 Not Found"), 23 + } 24 + } 9 25 10 26 #[actix_web::main] 11 27 async fn main() -> Result<()> { ··· 68 84 .route("/health", web::get().to(handlers::health_check)), 69 85 ) 70 86 .service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url))) 71 - .service(fs::Files::new("/", "./static").index_file("index.html")) 87 + .default_service(web::route().to(|req: actix_web::HttpRequest| async move { 88 + let path = req.path().trim_start_matches('/'); 89 + let path = if path.is_empty() { "index.html" } else { path }; 90 + serve_static_file(path).await 91 + })) 72 92 }) 73 93 .workers(2) 74 94 .backlog(10_000)