diff --git a/.env.example b/.env.example index 7b4ca89..2ac8d83 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ DATABASE_URL=postgresql://user:password@localhost/dbname SERVER_HOST=127.0.0.1 SERVER_PORT=8080 +JWT_SECRET=change-me-in-production diff --git a/Cargo.lock b/Cargo.lock index b3743bf..bce7d52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1857,6 +1857,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "6.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "6.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "7.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1897,6 +1931,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.27" @@ -2068,8 +2111,10 @@ dependencies = [ "dotenv", "jsonwebtoken", "lazy_static", + "mime_guess", "rand", "regex", + "rust-embed", "serde", "serde_json", "sqlx", @@ -2736,6 +2781,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -2832,6 +2887,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 09e3ac2..cb8fbfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ name = "simplelink" path = "src/lib.rs" [dependencies] +rust-embed = "6.8" jsonwebtoken = "9" actix-web = "4.4" actix-files = "0.6" @@ -28,4 +29,5 @@ chrono = { version = "0.4", features = ["serde"] } regex = "1.10" lazy_static = "1.4" argon2 = "0.5.3" -rand = { version = "0.8", features = ["std"] } \ No newline at end of file +rand = { version = "0.8", features = ["std"] } +mime_guess = "2.0.5" diff --git a/README.md b/README.md new file mode 100644 index 0000000..21adf3a --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# SimpleLink +A very performant and light (6mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres. + +![MainView](readme_img/mainview.jpg) + +![StatsView](readme_img/statview.jpg) + +## Build + +### From Source +First configure .env.example and save it to .env + +The project will not run withot DATABASE_URL set. (TODO add sqlite support) + +```bash +#set api-domain to where you will be deploying the link shortener, eg: link.example.com, default is localhost:8080 +git clone https://github.com/waveringana/simplelink && cd simplelink +./build.sh api-domain=localhost:8080 +cargo run +``` + +Alternatively if you want a binary form +```bash +./build.sh --binary +``` +then check /target/release for the binary named `SimpleGit` + +### From Docker +```bash +docker build --build-arg API_URL=http://localhost:8080 -t simplelink . +docker run simplelink -p 8080:8080 \ + -e JWT_SECRET=change-me-in-production \ + -e DATABASE_URL=postgres://user:password@host:port/database \ + simplelink +``` + +### From Docker Compose +Adjust the included docker-compose.yml to your liking, it includes a postgres config as well. diff --git a/build.sh b/build.sh index db82f91..91232a5 100755 --- a/build.sh +++ b/build.sh @@ -3,6 +3,7 @@ # Default values API_URL="http://localhost:8080" RELEASE_MODE=false +BINARY_MODE=false # Parse command line arguments for arg in "$@" @@ -16,6 +17,10 @@ do RELEASE_MODE=true shift ;; + --binary) + BINARY_MODE=true + shift + ;; esac done @@ -45,13 +50,9 @@ npm install npm run build cd .. -# Create static directory if it doesn't exist +# Create static directory and copy frontend build mkdir -p static - -# Clean existing static files rm -rf static/* - -# Copy built files to static directory cp -r frontend/dist/* static/ # Build Rust project @@ -62,15 +63,16 @@ if [ "$RELEASE_MODE" = true ]; then # Create release directory mkdir -p release - # Copy binary and static files to release directory + # Copy only the binary to release directory cp target/release/simplelink release/ - cp -r static release/ cp .env.example release/.env # Create a tar archive tar -czf release.tar.gz release/ echo "Release archive created: release.tar.gz" +elif [ "$BINARY_MODE" = true ]; then + cargo build --release else cargo build fi diff --git a/docker-compose.yml b/docker-compose.yml index ffa19b7..aaa273e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,11 +26,12 @@ services: - API_URL=${API_URL:-http://localhost:3000} container_name: shortener-app ports: - - "3000:3000" + - "8080:8080" environment: - DATABASE_URL=postgresql://shortener:shortener123@db:5432/shortener - SERVER_HOST=0.0.0.0 - - SERVER_PORT=3000 + - SERVER_PORT=8080 + - JWT_SECRET=change-me-in-production depends_on: db: condition: service_healthy diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2a871d8..3ee3b07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,6 @@ "version": "0.0.0", "dependencies": { "@emotion/react": "^11.14.0", - "@headlessui/react": "^2.2.0", "@hookform/resolvers": "^3.10.0", "@icons-pack/react-simple-icons": "^11.2.0", "@mantine/core": "^7.16.1", @@ -29,9 +28,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", - "react-simple-icons": "^1.0.0-beta.5", "recharts": "^2.15.0", - "simple-icons": "^14.4.0", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "zod": "^3.24.1" @@ -595,25 +592,6 @@ "version": "0.2.9", "license": "MIT" }, - "node_modules/@headlessui/react": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz", - "integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/react": "^0.26.16", - "@react-aria/focus": "^3.17.1", - "@react-aria/interactions": "^3.21.3", - "@tanstack/react-virtual": "^3.8.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": "^18 || ^19 || ^19.0.0-rc", - "react-dom": "^18 || ^19 || ^19.0.0-rc" - } - }, "node_modules/@hookform/resolvers": { "version": "3.10.0", "license": "MIT", @@ -722,10 +700,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jxnblk/simple-icons": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/@mantine/core": { "version": "7.16.1", "license": "MIT", @@ -1395,92 +1369,6 @@ "version": "1.1.0", "license": "MIT" }, - "node_modules/@react-aria/focus": { - "version": "3.19.1", - "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.19.1.tgz", - "integrity": "sha512-bix9Bu1Ue7RPcYmjwcjhB14BMu2qzfJ3tMQLqDc9pweJA66nOw8DThy3IfVr8Z7j2PHktOLf9kcbiZpydKHqzg==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/interactions": "^3.23.0", - "@react-aria/utils": "^3.27.0", - "@react-types/shared": "^3.27.0", - "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/interactions": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.23.0.tgz", - "integrity": "sha512-0qR1atBIWrb7FzQ+Tmr3s8uH5mQdyRH78n0krYaG8tng9+u1JlSi8DGRSaC9ezKyNB84m7vHT207xnHXGeJ3Fg==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/ssr": "^3.9.7", - "@react-aria/utils": "^3.27.0", - "@react-types/shared": "^3.27.0", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/ssr": { - "version": "3.9.7", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz", - "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/utils": { - "version": "3.27.0", - "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.27.0.tgz", - "integrity": "sha512-p681OtApnKOdbeN8ITfnnYqfdHS0z7GE+4l8EXlfLnr70Rp/9xicBO6d2rU+V/B3JujDw2gPWxYKEnEeh0CGCw==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/ssr": "^3.9.7", - "@react-stately/utils": "^3.10.5", - "@react-types/shared": "^3.27.0", - "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-stately/utils": { - "version": "3.10.5", - "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.5.tgz", - "integrity": "sha512-iMQSGcpaecghDIh3mZEpZfoFH3ExBwTtuBEcvZ2XnGzCgQjeYXcMdIUwAfVQLXFTdHUHGF6Gu6/dFrYsCzySBQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-types/shared": { - "version": "3.27.0", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.27.0.tgz", - "integrity": "sha512-gvznmLhi6JPEf0bsq7SwRYTHAKKq/wcmKqFez9sRdbED+SPMUmK5omfZ6w3EwUFQHbYUa4zPBYedQ7Knv70RMw==", - "license": "Apache-2.0", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.32.0", "cpu": [ @@ -1492,15 +1380,6 @@ "darwin" ] }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/@tailwindcss/node": { "version": "4.0.0", "license": "MIT", @@ -1570,33 +1449,6 @@ "vite": "^5.2.0 || ^6" } }, - "node_modules/@tanstack/react-virtual": { - "version": "3.11.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.3.tgz", - "integrity": "sha512-vCU+OTylXN3hdC8RKg68tPlBPjjxtzon7Ys46MgrSLE+JhSjSTPvoQifV6DQJeJmA8Q3KT6CphJbejupx85vFw==", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.11.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.11.3", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.3.tgz", - "integrity": "sha512-v2mrNSnMwnPJtcVqNvV0c5roGCBqeogN8jDtgtuHCphdwBasOZ17x8UV8qpHUh+u0MLfX43c0uUHKje0s+Zb0w==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -3488,13 +3340,6 @@ } } }, - "node_modules/react-simple-icons": { - "version": "1.0.0-beta.5", - "license": "MIT", - "dependencies": { - "@jxnblk/simple-icons": "^1.0.0" - } - }, "node_modules/react-smooth": { "version": "4.0.4", "license": "MIT", @@ -3715,17 +3560,6 @@ "node": ">=8" } }, - "node_modules/simple-icons": { - "version": "14.4.0", - "license": "CC0-1.0", - "engines": { - "node": ">=0.12.18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/simple-icons" - } - }, "node_modules/source-map": { "version": "0.5.7", "license": "BSD-3-Clause", diff --git a/readme_img/mainview.jpg b/readme_img/mainview.jpg new file mode 100644 index 0000000..30602e5 Binary files /dev/null and b/readme_img/mainview.jpg differ diff --git a/readme_img/statview.jpg b/readme_img/statview.jpg new file mode 100644 index 0000000..435cb49 Binary files /dev/null and b/readme_img/statview.jpg differ diff --git a/src/main.rs b/src/main.rs index 6b613d5..ca8bc16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,28 @@ use actix_cors::Cors; -use actix_files as fs; -use actix_web::{web, App, HttpServer}; +use actix_web::{web, App, HttpResponse, HttpServer}; use anyhow::Result; +use rust_embed::RustEmbed; use simplelink::check_and_generate_admin_token; use simplelink::{handlers, AppState}; use sqlx::postgres::PgPoolOptions; use tracing::info; +#[derive(RustEmbed)] +#[folder = "static/"] +struct Asset; + +async fn serve_static_file(path: &str) -> HttpResponse { + match Asset::get(path) { + Some(content) => { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + HttpResponse::Ok() + .content_type(mime.as_ref()) + .body(content.data.into_owned()) + } + None => HttpResponse::NotFound().body("404 Not Found"), + } +} + #[actix_web::main] async fn main() -> Result<()> { // Load environment variables from .env file @@ -68,7 +84,11 @@ async fn main() -> Result<()> { .route("/health", web::get().to(handlers::health_check)), ) .service(web::resource("/{short_code}").route(web::get().to(handlers::redirect_to_url))) - .service(fs::Files::new("/", "./static").index_file("index.html")) + .default_service(web::route().to(|req: actix_web::HttpRequest| async move { + let path = req.path().trim_start_matches('/'); + let path = if path.is_empty() { "index.html" } else { path }; + serve_static_file(path).await + })) }) .workers(2) .backlog(10_000)